Ember Data is a powerful library for managing data in Ember applications. It provides a consistent API for communicating with backends, caching data, and handling relationships. This guide covers everything from basic setup to advanced patterns.
What is Ember Data?
Ember Data is a data persistence library that:
- Normalizes API responses into predictable models
- Caches records in memory for fast access
- Manages relationships between models
- Handles dirty tracking and changes automatically
- Provides adapters for different API styles
Setup
Installation
Ember Data is included by default in new Ember apps:
ember new my-appFor existing apps:
ember install ember-dataModels
Models define the shape of your data:
// app/models/user.js
import Model, { attr } from '@ember-data/model';
export default class UserModel extends Model {
@attr('string') name;
@attr('string') email;
@attr('boolean', { defaultValue: false }) isAdmin;
@attr('date', { defaultValue: () => new Date() }) createdAt;
}Attribute Types
import Model, { attr } from '@ember-data/model';
export default class ProductModel extends Model {
@attr('string') name;
@attr('number') price;
@attr('boolean') isAvailable;
@attr('date') publishedAt;
@attr('array') tags; // Array of strings
@attr('object') metadata; // Plain object
}Custom Transformations
// app/transforms/money.js
import Transform from '@ember-data/serializer/transform';
export default class MoneyTransform extends Transform {
deserialize(serialized) {
return serialized / 100; // Convert cents to dollars
}
serialize(deserialized) {
return Math.round(deserialized * 100); // Convert dollars to cents
}
}Usage:
@attr('money') price;Relationships
One-to-One
// app/models/user.js
import Model, { attr, hasOne } from '@ember-data/model';
export default class UserModel extends Model {
@attr('string') name;
@hasOne('profile') profile;
}
// app/models/profile.js
import Model, { attr, belongsTo } from '@ember-data/model';
export default class ProfileModel extends Model {
@attr('string') bio;
@belongsTo('user') user;
}One-to-Many
// app/models/post.js
import Model, { attr, hasMany } from '@ember-data/model';
export default class PostModel extends Model {
@attr('string') title;
@attr('string') body;
@hasMany('comment') comments;
}
// app/models/comment.js
import Model, { attr, belongsTo } from '@ember-data/model';
export default class CommentModel extends Model {
@attr('string') body;
@belongsTo('post') post;
}Many-to-Many
// app/models/book.js
import Model, { attr, hasMany } from '@ember-data/model';
export default class BookModel extends Model {
@attr('string') title;
@hasMany('author') authors;
}
// app/models/author.js
import Model, { attr, hasMany } from '@ember-data/model';
export default class AuthorModel extends Model {
@attr('string') name;
@hasMany('book') books;
}Adapters
Adapters define how your app communicates with the backend:
REST Adapter
// app/adapters/application.js
import RESTAdapter from '@ember-data/adapter/rest';
export default class ApplicationAdapter extends RESTAdapter {
namespace = 'api';
host = 'https://api.example.com';
// Custom headers
headers = {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/vnd.api+json'
};
// Customize URLs
buildURL(modelName, id, snapshot, requestType, query) {
let url = super.buildURL(modelName, id, snapshot, requestType, query);
if (requestType === 'findRecord' && id) {
return `${url}/public`;
}
return url;
}
// Handle errors
handleResponse(status, headers, payload) {
if (status === 401) {
// Redirect to login
}
return super.handleResponse(status, headers, payload);
}
}JSON:API Adapter
// app/adapters/application.js
import JSONAPIAdapter from '@ember-data/adapter/json-api';
export default class ApplicationAdapter extends JSONAPIAdapter {
namespace = 'api/v1';
}Custom Adapter for Specific Model
// app/adapters/user.js
import ApplicationAdapter from './application';
export default class UserAdapter extends ApplicationAdapter {
buildURL(modelName, id, snapshot, requestType, query) {
return `https://auth-api.example.com/users/${id || ''}`;
}
}Serializers
Serializers format incoming and outgoing data:
JSON:API Serializer
// app/serializers/application.js
import JSONAPISerializer from '@ember-data/serializer/json-api';
export default class ApplicationSerializer extends JSONAPISerializer {
// Key for attributes
keyForAttribute(attr) {
return attr;
}
// Key for relationships
keyForRelationship(key) {
return key;
}
// Normalize responses
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
// Custom normalization logic
return super.normalizeResponse(store, primaryModelClass, payload, id, requestType);
}
}REST Serializer
// app/serializers/user.js
import RESTSerializer from '@ember-data/serializer/rest';
export default class UserSerializer extends RESTSerializer {
normalize(modelClass, resourceHash) {
// Transform underscore_case to camelCase
const transformed = {};
for (let key in resourceHash) {
const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
transformed[camelKey] = resourceHash[key];
}
return super.normalize(modelClass, transformed);
}
serialize(snapshot, options) {
const json = super.serialize(snapshot, options);
// Convert back to underscore_case
const transformed = {};
for (let key in json) {
const snakeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
transformed[snakeKey] = json[key];
}
return transformed;
}
}Store
The store is the central repository of data:
Finding Records
import Route from '@ember/routing/route';
export default class UsersRoute extends Route {
model() {
// Find all
return this.store.findAll('user');
// Find by ID
return this.store.findRecord('user', 1);
// Find with query
return this.store.query('user', { page: 1, limit: 10 });
}
}Creating Records
// Create new record
const user = this.store.createRecord('user', {
name: 'John Doe',
email: 'john@example.com'
});
// Save to server
await user.save();
// Create and save in one step
const user = await this.store.createRecord('user', {
name: 'Jane Doe'
}).save();Updating Records
const user = await this.store.findRecord('user', 1);
// Update properties
user.name = 'Jane Smith';
user.email = 'jane@example.com';
// Save changes
await user.save();
// Or use setProperty
user.setProperties({
name: 'Jane Smith',
email: 'jane@example.com'
});
await user.save();Deleting Records
const user = await this.store.findRecord('user', 1);
// Delete from store and server
await user.destroyRecord();
// Delete without server request
user.unloadRecord();Working with Relationships
Loading Relationships
const post = await this.store.findRecord('post', 1);
// Load async relationship
const comments = await post.comments;
// Load with options
const comments = await post.comments.reload();
// Check if loaded
if (post.comments.isLoaded) {
// Use comments
}Creating Related Records
const post = await this.store.findRecord('post', 1);
const comment = this.store.createRecord('comment', {
body: 'Great post!',
post: post
});
await comment.save();Modifying Relationships
const user = await this.store.findRecord('user', 1);
const post1 = await this.store.findRecord('post', 1);
const post2 = await this.store.findRecord('post', 2);
// Add to hasMany
user.posts.pushObject(post1);
user.posts.pushObject(post2);
// Remove from hasMany
user.posts.removeObject(post1);
// Replace all
user.posts.setObjects([post1, post2]);
// Save changes
await user.save();Queries
Querying Data
// Simple query
const activeUsers = await this.store.query('user', {
filter: { status: 'active' }
});
// Complex query
const results = await this.store.query('post', {
include: 'author,comments',
sort: '-created_at',
page: { size: 20, number: 1 },
filter: {
status: 'published',
category: 'technology'
}
});Custom Query Parameters
// app/adapters/post.js
export default class PostAdapter extends ApplicationAdapter {
query(store, type, query) {
const url = this.buildURL(type.modelName, null, null, 'query', query);
// Custom query logic
if (query.search) {
return this.ajax(url, 'GET', {
data: {
q: query.search,
limit: query.limit
}
});
}
return super.query(store, type, query);
}
}Errors and Validation
Handling Errors
try {
await user.save();
} catch (error) {
// Access errors
if (error.errors) {
error.errors.forEach(error => {
console.error(error.detail);
});
}
// Get errors for specific attribute
user.errors.forEach(({ attribute, message }) => {
console.log(`${attribute}: ${message}`);
});
}Client-Side Validation
// app/models/user.js
import Model, { attr } from '@ember-data/model';
import { validatePresence, validateFormat } from 'ember-changeset-validations/validators';
export default class UserModel extends Model {
@attr('string') email;
@attr('string') name;
// Custom validation
get validEmail() {
const emailRegex = /^[^@]+@[^@]+\.[^@]+$/;
return emailRegex.test(this.email);
}
// Check before save
async save() {
if (!this.validEmail) {
this.errors.add('email', 'Invalid email format');
return Promise.reject(this.errors);
}
return super.save(...arguments);
}
}Performance Optimization
Reloading
// Reload from server
const user = await this.store.findRecord('user', 1, { reload: true });
// Reload all
await this.store.findAll('user', { reload: true });Background Reload
// Fetch in background, use cached data immediately
const user = await this.store.findRecord('user', 1, {
backgroundReload: true
});Peek (No Network Request)
// Peek at cached records (no network request)
const allUsers = this.store.peekAll('user');
const user = this.store.peekRecord('user', 1);
// Check if record exists in cache
const hasCached = this.store.hasRecordForId('user', 1);Unloading Records
// Unload all records of a type
this.store.unloadAll('user');
// Unload specific record
const user = this.store.peekRecord('user', 1);
user.unloadRecord();Testing
Testing Models
// tests/unit/models/user-test.js
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Model | user', function(hooks) {
setupTest(hooks);
test('it computes full name', function(assert) {
const store = this.owner.lookup('service:store');
const user = store.createRecord('user', {
firstName: 'John',
lastName: 'Doe'
});
assert.equal(user.fullName, 'John Doe');
});
test('it validates email format', function(assert) {
const store = this.owner.lookup('service:store');
const user = store.createRecord('user', {
email: 'invalid-email'
});
assert.notOk(user.validEmail);
});
});Best Practices
- 1Use JSON:API: Leverage the JSON:API standard for consistency
- 2Normalize Data: Keep your data normalized and avoid duplication
- 3Cache Wisely: Use peek when you don't need network requests
- 4Handle Errors: Always handle save and load errors gracefully
- 5Use Relationships: Leverage Ember Data's relationship management
- 6Test Thoroughly: Test models, adapters, and serializers
Conclusion
Ember Data provides a robust solution for data management in Ember applications. Its conventions eliminate boilerplate, while its flexibility allows customization for any API. Master these patterns to build applications that are maintainable and scalable.
The key is understanding the request lifecycle: finding records, loading relationships, making changes, and persisting data. With Ember Data, you get all of this out of the box, letting you focus on building great features instead of wrestling with data fetching.