One decision you’re faced with when starting a new project is how you’re going to structure your API responses. If you’re using recent versions of Ember and Ember Data, then there’s a good chance you’re using the json-api specification as the basis for your api, since that works out of the box with Ember Data.
Ember Data 2.4 introduced a feature referred to as Reference Unification. The reference unification added two new methods to DS.Model instances: belongsTo and hasMany , which should not be confused with the DS.belongsTo and DS.hasMany functions that are used to declare relations on your models.
These methods, combined with Ember Data’s adoption of json-api standard, give you some powerful tools to inspect and control related fields on your Ember Data models.
In this post, we’ll look at how some of the choices you make in your API design affect the behavior of your Ember Data models.
All of the code samples use an example set of models that look like the following models below. Specifically, we’re looking at the Book model and how you can manage the lifecycle of the related models: Author, and Chapters.
// app/models/book.js
export default DS.Model.extend({
author: DS.belongsTo('user'),
chapters: DS.hasMany('chapter')
});
// app/models/user.js
export default DS.Model.extend({
name: DS.attr('string'),
books: DS.hasMany('book')
});
// app/models/chapter.js
export default DS.Model.extend({
book: DS.belongsTo('book')
});
JSON API and Loading Relations
In our example, we want to be able to fetch a book and then fetch the related author and chapter resources.
There are two ways of defining relationships in the json-api spec. You can either provide the IDs of the related resources (the “data” method) or provide a url that the client can use to fetch the related resources (the “link” method).
Relations as “Data”
The first way to return relations in your API is to return the actual “data” of the relationship. That just means you return the IDs of resources that related to the resource you requested. A json body using this “data” style looks like this:
{
"type": "book",
"id": 1,
"relationships": {
"author": {
"data": {"type": "user", "id": 2}
},
"chapters": {
"data": [
{"type": "chapter", "id": 1},
{"type": "chapter", "id": 2}
]
}
}
}
Checking and Loading “Data” Relations
The benefit of using this method is that it’s obvious who the author is and how many chapters there are, just by looking at the json response. What that means for Ember is that you can inspect the IDs of your relations synchronously and without triggering any Ajax requests. Compare this to doing something like book.get(‘author.id’) which will trigger an Ajax request if the author has not been loaded yet.
// We can easily check the ids of the relations
// without having to fetch the related model from the api
> book.belongsTo('author').id();
1
> book.hasMany('chapters').ids();
["1", "2"]
// We know these relations haven't loaded yet,
// because their values are null.
// We do know the book has these relations on the server though,
// because we have their ids.
> book.belongsTo('author').value();
null
> book.hasMany('chapters').value();
null
// Loading relations will fire one ajax request for each of the related resources
> book.get('author');
// GET "http://localhost:4200/users/1"
// Two chapters means two ajax requests
> book.get('chapters');
// GET "http://localhost:4200/chapters/1"
// GET "http://localhost:4200/chapters/2"
Reloading “Data” Relations
Reloading related resources with the data method is pretty straightforward too (though it will seem a bit backwards once you get through the “links” way of doing things). To see if any relations have been added, removed, or changed, you simply reload the book model to get the new IDs.
// This is what we had before
> book.belongsTo('author').id();
1
> book.hasMany('chapters').ids();
["1", "2"]
// The related models have been loaded already
> book.belongsTo('author').value().toString();
"<example-app@model:user::ember315:1>"
> book.hasMany('chapters').value().toString();
["<example-app@model:chapter::ember315:1>",
"<example-app@model:chapter::ember315:2>"]
/*
While you're using the app, the following
changes happen on the server:
- the author changes from user 1 to user 2
- chapter 2 is deleted
- chapter 3 is added as a new chapter
*/
// PROBLEM: Reloading the relations will reload existing relations,
// but it won't tell you if any relations have been added, removed,
// or changed.
> book.belongsTo('author').reload();
// GET "http://localhost:4200/users/1"
> book.hasMany('chapters').reload();
// GET "http://localhost:4200/chapters/1"
// GET "http://localhost:4200/chapters/2"
// SOLUTION: To get the new references,
// you have to reload the book
> book.reload();
// GET "http://localhost:4200/books/1"
// Now the ids are updated
> book.belongsTo('author').id();
2
> book.hasMany('chapters').ids();
["1", "3"]
// However the values have been cleared because you've reloaded
// the book model - they will be repopulated next time you
// access or load them
> book.belongsTo('author').value();
null
> book.hasMany('chapters').value();
null
// Now the relational fields are fetching the correct data
> book.get('author');
// GET "http://localhost:4200/users/2"
// Only one ajax call gets made here,
// because Chapter 1 is already cached in the localstorage
> book.get('chapters');
// GET "http://localhost:4200/chapters/3"
Benefits:
– You always know if a related field has a value on the server without additional Ajax requests. There is no need to query the API for data if you know a relation is empty.
– Related models are referenced directly, so Ember Data is able to use the local store more efficiently by avoiding redundant Ajax requests.
– This is the only way to side-load relations.
Drawbacks:
– hasMany relations load each individual relation with a separate Ajax request. If you have a lot of related models, you’ll have a lot of requests fired off by your browser the first time you load those models (unless you side-load). That may cause significant performance issues.
– Reloading relations can be a bit unintuitive, because you reload the parent model instead of requesting a reload for a relation.
– You can’t reload just a single relation at a time.
– On the server, generating a list of related IDs for every API request may be prohibitively expensive and can slow down your API if not optimized properly.
Relations as “Links”
Returning relations as links is the alternative to returning the IDs of the related objects.
A JSON payload using links would look like this:
{
"type": "book",
"id": 1,
"relationships": {
"author": {
"links": "/books/1/author"
},
"chapters": {
"links": "/books/1/chapters"
}
}
}
Checking and Loading “Links” Relations
The major difference from the data method is that you no longer know what the related objects are until you request them from the server. You’ll see that this has some benefits over the data method, depending on your requirements.
// These relations don't have a value, and we can’t check the IDs,
// so we don't know if they are actually empty or if they haven't
// been fetched yet.
> book.belongsTo('author').value();
null
> book.hasMany('chapters').value();
null
// Relations are fetched using the url provided by the api.
// Each relation triggers exactly one Ajax request.
> book.get('author');
// GET "http://localhost:4200/books/1/author"
> book.get('chapters');
// GET "http://localhost:4200/books/1/chapters"
Reloading “Links” Relations
Reloading related resources when using the links attribute involves simply calling a reload on the related attribute. Ember Data has no way to cache a link reference, so every reload will trigger an Ajax request to fetch up to date data.
// The related models have been loaded already
> book.belongsTo('author').value().toString();
"<example-app@model:user::ember315:1>"
> book.hasMany('chapters').value().toString();
["<example-app@model:chapter::ember315:1>",
"<example-app@model:chapter::ember315:2>"]
/*
While you're using the app, the following
changes happen on the server:
- the author changes from user 1 to user 2
- chapter 2 is deleted
- chapter 3 is added as a new chapter
*/
// Each reload triggers the same request as when it first loaded
> book.belongsTo('author').reload();
// GET "http://localhost:4200/books/1/author"
> book.hasMany('chapters').reload();
// GET "http://localhost:4200/books/1/chapters"
// The new values are now present in the relations without any extra steps
> book.belongsTo('author').value().toString();
"<example-app@model:user::ember315:2>"
> book.hasMany('chapters').value().toString();
["<example-app@model:chapter::ember315:1>", "<example-app@model:chapter::ember315:3>"]
Benefits:
- Loading a relation will always trigger exactly one request instead of one request per related model like with the data method.
- Relations are not cached, so the related values will always be consistent with the server every time you reload.
- Reloading the parent model will not clear the relationships like the data method does.
- Allows for granular reloading of a single relation at a time
Drawbacks:
- You’re never able to tell the difference between “this book’s author has not been loaded yet” and “this book doesn’t have an author at all”. Compare this to the “data” method where the .id() method tells you if a relation is empty or just not loaded.
- You don’t know what you’re going to get until you make a request to the server. This may be a benefit if you don’t immediately care to know about a relation or if you never need that data.
- Side-loading data is not possible, because because the book model doesn’t know which of the related models are associated until a request is made.
Combining “Links” and “Data”
Ember Data will also work just fine if you combine the two strategies and return both link and data attributes. Returning both relationship attributes combines some of the benefits of each method while mitigating some of the drawbacks.
A payload providing both pieces of information would look something like this:
{
"type": "book",
"id": 1,
"relationships": {
"author": {
"links": "/books/1/author",
"data": {"type": "user", "id": 2}
},
"chapters": {
"links": "/books/1/chapters",
"data": [
{"type": "chapter", "id": 1},
{"type": "chapter", "id": 2}
]
}
}
}
When loading resources with both types of relationship references, Ember Data will use the “data” attribute for initial loads and the “links” attribute for reloading. I won’t include examples for the combined scenario, because they would be exact copies of the respective examples from the previous sections.
Conclusions
I think that Ember Data’s behaviour when provided with both “links” and “data” is a good indicator for the situations in which each of the two styles excel.
“Data” references are (in some cases) superior when loading data, because they allow you to side-load related resources and make use of the caching provided by the Ember Data store.
“Link” references are superior when reloading data, because all you need to do is make a single Ajax request to ensure that your client-side data is consistent with the server data.
Banner image: Photo by Pamoni Photograph