Ember Data
Ember Data is a normalized, identity-mapped store for your server data. Two ways to think about it:
- Old school (legacy mental model): A model layer with conventional Adapter/Serializer pipelines that target JSON:API by default. Suited to REST APIs.
- New school (post-RFC 716): A request-builder + request-manager system (
@ember-data/request) with reactive cache. Adapters/Serializers become optional; you build requests directly.
Modern apps mix both. New code should prefer @ember-data/request for fetches and the cache for reactivity, but keep Model classes and findRecord/query for the convenience.
Models
// app/models/post.ts
import Model, { attr, belongsTo, hasMany } from '@ember-data/model';
import type { AsyncBelongsTo, AsyncHasMany } from '@ember-data/model';
import type UserModel from './user';
import type CommentModel from './comment';
export default class PostModel extends Model {
@attr('string') declare title: string;
@attr('string') declare body: string;
@attr('date') declare publishedAt: Date | null;
@attr('boolean', { defaultValue: false }) declare isDraft: boolean;
@belongsTo('user', { async: true, inverse: 'posts' })
declare author: AsyncBelongsTo<UserModel>;
@hasMany('comment', { async: true, inverse: 'post' })
declare comments: AsyncHasMany<CommentModel>;
get isPublished(): boolean {
return !this.isDraft && this.publishedAt !== null;
}
}
declare module 'ember-data/types/registries/model' {
export default interface ModelRegistry {
post: PostModel;
}
}
@attr(transformName, options)declares a transformed primitive. Built-in transforms:string,number,boolean,date. Custom transforms inapp/transforms/.@belongsTo/@hasManydeclare relationships. Always specifyasyncandinverseexplicitly — implicit inverses are deprecated.- The
ModelRegistryaugmentation givesstore.findRecord('post', id)a typed return.
The store
@service('store') is the entry point. Common APIs:
@service declare store: Store;
// Fetch by id (cache-first, falls back to network)
const post = await this.store.findRecord('post', id);
const fresh = await this.store.findRecord('post', id, { reload: true });
const background = await this.store.findRecord('post', id, { backgroundReload: true });
// Query the server
const recent = await this.store.query('post', { sort: 'recent', page: 1 });
// All currently loaded records (no network)
const cached = this.store.peekAll('post');
const id);
// Create / save / unload
const draft = this.store.createRecord('post', { title: 'New' });
await draft.save(); // POST
draft.title = 'Updated';
await draft.save(); // PATCH
await draft.destroyRecord(); // DELETE + remove from store
draft.unloadRecord(); // remove from store, no network
findRecord vs query vs queryRecord vs peek*
| Method | Returns | Hits network? |
|---|---|---|
findRecord(type, id) |
One record | Only if not in cache or reload: true. |
findAll(type) |
All of type | Yes (background-reloads if cached). |
query(type, params) |
RecordArray | Always. |
queryRecord(type, params) |
One record (or null) |
Always. |
peekRecord(type, id) |
One or null |
Never. |
peekAll(type) |
Live array of cached records | Never. |
Don't use findAll casually — for a list endpoint with filters/pagination, use query.
Relationships
// Async — returns a Promise<Record>
const author = await post.author;
const comments = await post.comments;
// Sync (when async: false) — returns directly
post.tags.forEach(t => /* ... */);
// In templates, async relationships unwrap automatically:
// {{post.author.name}}
Mutating relationships:
// belongsTo
post.author = newUser; // assignment
await post.save();
// hasMany — operate on the resolved array
const comments = await post.comments;
comments.push(newComment); // tracked via the relationship
// or: comments.splice(0, 0, newComment);
await post.save();
Rule: inverse must be set explicitly.
@hasMany('comment', { async: true, inverse: 'post' })and oncomment:@belongsTo('post', { async: false, inverse: 'comments' }). Without inverses, deletes won't propagate, additions can duplicate, and you'll spend hours debugging "why is the array empty."
Adapter + Serializer (legacy pipeline)
By default, requests go through app/adapters/application.ts. The default adapter speaks JSON:API.
// app/adapters/application.ts
import JSONAPIAdapter from '@ember-data/adapter/json-api';
import { service } from '@ember/service';
export default class ApplicationAdapter extends JSONAPIAdapter {
@service declare session: SessionService;
host = 'https://api.example.com';
namespace = 'v1';
get headers() {
return {
Authorization: `Bearer ${this.session.token}`,
Accept: 'application/vnd.api+json',
};
}
}
Common adapter overrides:
host,namespace,headers.pathForType(type)for non-pluralized URLs (e.g.'fish'→'fish'instead of'fishes').urlForFindRecord(id, type, snapshot)for irregular endpoints.
Serializers translate API payloads to JSON:API shape:
// app/serializers/application.ts
import RESTSerializer from '@ember-data/serializer/rest';
// or: JSONSerializer, JSONAPISerializer
If your API is already JSON:API, you usually don't need a serializer. If it's REST or bespoke, you'll write normalizeResponse overrides — at which point ask yourself whether @ember-data/request (below) is a better fit for new endpoints.
@ember-data/request — the new request layer
For new code, prefer building a request manager with handlers:
// app/services/request-manager.ts
import RequestManager from '@ember-data/request';
import Fetch from '@ember-data/request/fetch';
import { CacheHandler } from '@ember-data/store';
const manager = new RequestManager();
manager.use([Fetch]);
manager.useCache(CacheHandler);
export default manager;
Then make requests via builders:
import { findRecord } from '@ember-data/json-api/request';
const { content: post } = await this.store.request(findRecord('post', '42'));
For custom REST endpoints, write a builder:
function activatePost(id: string) {
return {
url: `/api/posts/${id}/activate`,
method: 'POST',
cacheOptions: { reload: true },
};
}
await this.store.request(activatePost(post.id));
Builders are plain functions — easy to test, easy to type. You no longer need a custom adapter for one-off endpoints.
Saving and validation
const post = this.store.createRecord('post', { title: 'Draft' });
post.title = ''; // local mutation
const valid = post.title.length > 0;
try {
await post.save();
} catch (err) {
// err is a JSON:API errors document by default
console.log(post.errors); // accessor for per-attribute errors
console.log(post.isValid); // false
}
Server-returned errors land on record.errors, which is reactive — bind it to your form UI.
Reloading and background reloads
await post.reload(); // explicit refetch
await post.belongsTo('author').reload();
await post.hasMany('comments').reload();
In a route's model:
async model({ id }: { id: string }) {
return this.store.findRecord('post', id, {
backgroundReload: true, // returns cached, refreshes silently
});
}
Performance considerations
- The store is identity-mapped: loading the same record twice from different endpoints merges into one record. Stale data on one endpoint can leak into another.
includesare your friend:this.store.findRecord('post', id, { include: 'author,comments.author' })issues one request and hydrates relationships. Avoids N+1.reload: trueinvalidates cache for that resource. Don't pass it casually.peek*is synchronous and cheap. For derived UI, prefer peek + tracked recompute over re-fetching.- Avoid
findAllfor lists. It often blows up bandwidth on production data sets.
When NOT to use Ember Data
- One-off RPC endpoints (search, analytics events, "ping"). Use plain
fetchor@ember-data/requestwithout a model. - Streaming or push-only data (websockets, SSE) — push into the store via
store.push({ data: { ... } })if you want it cached, or keep it in a service. - Massively denormalized aggregate endpoints (dashboards). These rarely fit the model abstraction; prefer raw fetch + tracked POJOs.
TS gotchas
- Always declare async/sync types: use
AsyncBelongsTo<T>/AsyncHasMany<T>forasync: true, plainT/T[]for sync. Glint reads these for template type-checks. declarekeyword on attribute fields prevents TS from emitting an initializer that breaks the decorator.ModelRegistryaugmentation givesstore.findRecord('post', id)a typedPromise<PostModel>.
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Missing inverse: |
Inconsistent relationship state, deprecation warnings. | Add inverse: 'fieldName' on both sides. |
Mutating post.tags in place via array methods |
Sometimes works, sometimes not. | Resolve the relationship, then use array methods on the resolved array (await post.tags). |
Using findAll everywhere |
Fetches the whole table. | Use query with filters/pagination. |
| Custom REST API + custom serializer + custom adapter | Brittle, lots of code to debug. | Build with @ember-data/request builders. |
await store.findRecord(...) from a component without route preload |
Loading flicker, no error boundary. | Fetch in the route's model hook. |
Forgetting Accept: application/vnd.api+json for JSON:API |
Server returns wrong content type. | Set in adapter headers. |
Verification
- Every relationship has explicit
asyncandinverse. - Each model has a
ModelRegistryaugmentation. - List endpoints use
query, notfindAll. - Route-level data is fetched in
model, not in component constructors. - One-off endpoints use
@ember-data/requestbuilders orfetch, not custom adapters. - No silent overrides of
normalizeResponsewithout tests.
See also
- Official deep dive: api.emberjs.com and the WarpDrive guides (newer Ember Data docs are publishing under the WarpDrive brand).
ember-routing-and-models— fetching in routes.ember-ecosystem-addons—ember-concurrencyfor cancellable searches that hit the store.ember-testing— Mirage scenarios +setupMiragefor store-driven tests.