Displaying Asynchronous Data in Templates
In Ember, a situation we commonly run into is needing to display asynchronous data in a template. A lot of the time this works really well, for example: Ember has some smarts around displaying related models within templates.
The below example shows a simple library application displaying authors and their published books:
import DS from 'ember-data';
export default DS.Model.extend({
title: DS.attr('string'),
year: DS.attr('number'),
author: DS.belongsTo('author')
});
import DS from 'ember-data';
export default DS.Model.extend({
name: DS.attr('string'),
books: DS.hasMany('book')
});
import Ember from 'ember';
export default Ember.Route.extend({
model () {
return this.get('store').findAll('author');
}
});
<h2>Authors</h2>
<ul>
{{#each model as |author|}}
<li>{{author.name}}</li>
<ul>
{{#each author.books as |book|}}
<li>{{book.title}} ({{book.year}})</li>
{{/each}}
</ul>
{{/each}}
</ul>
As you can see, despite the fact that getting the books is an asynchronous traversal of a relationship, the template renders correctly once the books have been fetched.
This is all well and good, but when you want to display some asynchronous data that isn’t just properties on a related model it gets more difficult.
For example, what if we wanted to asterisk authors that had published in the last two years? This requires us to get the related books and run a little logic. This seems like a useful function to have on our model so we can reuse it throughout our application.
import DS from 'ember-data';
export default DS.Model.extend({
name: DS.attr('string'),
books: DS.hasMany('book'),
// Have any of their books been published in the last two years.
hasPublishedRecently () {
return this.get('books').then(books => {
const currentYear = new Date().getFullYear();
const years = books.map(b => b.get('year'));
return years.some(y => y >= currentYear - 2);
});
}
});
However, now we can no longer use that value directly in the template – in fact, it requires some restructuring of our application. We could possibly pass in the resolved promise as part of our route model, however this will be clunky as we have a list of authors. Another approach is to create a separate component that is responsible for displaying the author. This component could call hasPublishedRecently and set a property on the component once the promise resolves.
import Ember from 'ember';
export default Ember.Component.extend({
didReceiveAttrs () {
this._super(...arguments);
this.get('author').hasPublishedRecently().then(result => {
this.set('hasPublishedRecently', result);
});
},
hasPublishedRecently: false
});
{{author.name}}{{if hasPublishedRecently '*'}}
<h2>Authors</h2>
<ul>
{{#each model as |author|}}
<li>{{display-author author=author}}</li>
<ul>
{{#each author.books as |book|}}
<li>{{book.title}} ({{book.year}})</li>
{{/each}}
</ul>
{{/each}}
</ul>
<p><em>* Published Recently</em></p>
This works, but requires us to add a new component as well as a chunk of extra code just to display the resolved promise value.
Using DS Promises
Ember does provide another way to do this. Rather than a function on the model we can use a computed property and if it returns a special type of object it can be used directly in the template. Ember Data provides these two classes:
- DS.PromiseArray
- DS.PromiseObject
They use the PromiseProxyMixin to give the Ember objects extra properties and methods that the templates can work with.
To demonstrate, we can adapt the previous example to use a computed property and return a PromiseObject instance instead of a plain promise.
import DS from 'ember-data';
import Ember from 'ember';
export default DS.Model.extend({
name: DS.attr('string'),
books: DS.hasMany('book'),
// Have any of their books been published in the last two years.
hasPublishedRecently: Ember.computed('books', function () {
const promise = this.get('books').then(books => {
const currentYear = new Date().getFullYear();
const years = books.map(b => b.get('year'));
return years.some(y => y >= currentYear - 2);
});
return DS.PromiseObject.create({promise});
})
});
Now we can use it directly in our template:
<h2>Authors</h2>
<ul>
{{#each model as |author|}}
<li>{{author.name}}{{if author.hasPublishedRecently.content '*'}}</li>
<ul>
{{#each author.books as |book|}}
<li>{{book.title}} ({{book.year}})</li>
{{/each}}
</ul>
{{/each}}
</ul>
<p><em>* Published Recently</em></p>
Notice we now need to use the content property of the object to display it in the template? If you want to ensure that the promise has resolved you can also use the properties isPending or isSettled . See the PromiseProxyMixin for others.
For example, if we had a promise telling us if the book was available or on loan, we could wrap it in an unless isPending block so nothing will show until the result was known:
<li>
{{book.title}} ({{book.year}})
{{#unless book.isAvailable.isPending}}
- {{if book.isAvailable.content 'available' 'on loan'}}
{{/unless}}
</li>
Drawbacks of DS Promises
Forgetting to use content or isPending properties
One drawback of this approach is that you now need awareness of whether you are dealing with a promise or a value in your templates. One convention we’ve used in our projects is giving the computed properties resolving to promises a Promise suffix.
import DS from 'ember-data';
import Ember from 'ember';
export default DS.Model.extend({
name: DS.attr('string'),
books: DS.hasMany('book'),
// Have any of their books been published in the last two years.
hasPublishedRecentlyPromise: Ember.computed('books', function () {
const promise = this.get('books').then(books => {
const currentYear = new Date().getFullYear();
const years = books.map(b => b.get('year'));
return years.some(y => y >= currentYear - 2);
});
return DS.PromiseObject.create({promise});
})
});
This adds a few more keystrokes but does make it much clearer in templates when dealing with these properties.
Boilerplate
This approach still requires some boilerplate code. It needs the promise to be wrapped in a DS.PromiseObject.create() call and accessing the content property in the template. It may be possible to remove this with a helper that can take a regular promise and abstract away the details of PromiseObject and optionally wrap the template code within an unless isPending block.
Although we haven’t tried it, this library also looks promising and solves the same problem.
References
To see a working example of this code, you can check out this repository.