Last reviewed in July 2019 with Ember Octane
Should we use promises in computed properties?
In general, the answer is no.
The need to return a promise in a computed property very likely originates in Ember Data, where relationships are async
by default and encourage this pattern. Async means that the relationship will always be accessed asynchronously (through a promise).
export default class RecipeModel extends Model {
@hasMany() ingredients;
// ^ equivalent to @hasMany({ async: true }) ingredients;
}
The relationship ingredients
will be accessed via recipe.ingredients.then(i => ...)
.
Trying to derive a value from ingredients, such as vegetarian ingredients, could look something like this:
vegetarianIngredients = computed('ingredients.@each.vegetarian', function() {
// this returns a promise
return this.get('ingredients').then(ingredients => {
return ingredients.filterBy('vegetarian');
});
})
Computed properties are essentially accessors, which serve as an implementation of the Uniform Access Principle.
All services offered by a module should be available through a uniform notation, which does not betray whether they are implemented through storage or through computation.
Clients of the Recipe
model will call vegetarianIngredients
regardless of whether it's a real property (storage) or a computed property (computation). The key point is that both are expected to return the actual value, i.e. an array of vegetarian ingredients.
But the vegetarianIngredients
property above is not returning an array of vegetarian ingredients – it's returning a Promise
as the value.
Aside from introducing a new naming convention there is no way to distinguish a standard computed property vs a “promise” computed property. In addition, promises don't work in templates. At least not without a specific helper like async-await.
If that is the case, why does the recipe.ingredients
async relationship work perfectly well in the following template?!
{{#each recipe.ingredients as |ingredient|}}
{{ ingredient.name }}
{{/each}}
How is this template able to loop through a promise? Something specific to the each
helper?
No. Ember Data relationships do not return promises! It uses a promise proxy object (mixes in PromiseProxyMixin) which behaves like an array but kicks off a network request and populates this array-type object once that promise is fulfilled.
The issue here is a property triggering a side-effect. By their nature, computed properties are pure functions and should behave as such: idempotent and side-effect free.
Here is an example of another framework that doesn't encourage working with async in an accessor:
[…] a getter that returns a Future should not kick-off the work represented by the future, since getters appear idempotent and side-effect free
Computed properties should be simple and predictable. Calling recipe.get('ingredients')
or recipe.ingredients
should return that value and only that value. After years of working with Ember Data in different scenarios, I have come to believe that “async by default” in Ember Data is unhealthy.
The fine folks at EmberMap have made a great case: The case against async relationships in Ember Data.
Our main gripe with this API is that it combines the concerns of local data access with remote data fetching into a single method call. This makes it harder for developers to write expressive, intention-revealing code, especially because data loading is such a non-trivial part of developing Ember applications.
[…]
With async relationships, calls to .get can now introduce asynchrony into our applications in subtle ways. Asynchronous code is one of the things that makes JavaScript UI development so challenging, and async relationships will inevitably lead to surprises. Asynchrony should be dealt with explicitly and deliberately in our applications.
They explain how their Ember Data Storefront add-on helps alleviate this issue. Among many goodies, it introduces a specific API called load
to fetch async resources, such that get
always returns a local value (“Different APIs for data access and data loading.")
Snacks is the best of Ember Octane in a highly digestible monthly newsletter. (No spam. EVER.)
Are you wanting to derive from an Ember Data async property? If so, my first recommendation is to use Storefront, specifically the Loadable
mixin.
Once relationships are made sync and loaded, standard computed properties can be created as usual!
vegetarianIngredients = computed('ingredients.@each.vegetarian', function() {
return this.get('ingredients').filterBy('vegetarian');
})
These properties can be used in templates without any underlying magic:
{{#each vegetarianIngredients as |ingredient|}}
{{ ingredient.name }}
{{/each}}
If we want to render a newly-fetched relationship (or any other promise for that matter) we can use Ember Concurrency:
loadIngredients = task(function*() {
yield this.recipe.load('ingredients');
});
(Notice Storefront's load
API.)
Now our template can look like this:
{{#if loadIngredients.lastSuccessful}}
{{#each vegetarianIngredients as |ingredient|}}
{{ ingredient.name }}
{{/each}}
{{/if}}
Where the loading can be triggered manually with a button in the UI
<button {{on 'click' (perform this.loadIngredients)}}>
Load ingredients
</button>
or via a component lifecycle hook:
import Component from '@glimmer/component';
import { task } from 'ember-concurrency';
export default class RecipeComponent extends Component {
constructor() {
super(...arguments);
this.loadIngredients.perform();
}
loadIngredients = task(function*() {
yield this.recipe.load('ingredients');
})
get vegetarianIngredients() {
return this.recipe.ingredients.filterBy('vegetarian');
}
}
As discussed in Should Components Load Data? the main models backing a template (those “driven by the URL”) should always be loaded in routes, potentially sideloading relationships. Ember provides a remarkable routing system, take advantage of it and don't reinvent the wheel!
For cases in which promise resolution in the route is unnatural (ancillary async data, contrived RSVP.hash
structures) I recommend using Ember Concurrency as shown above.
Does this clarify confusion about dealing with promises? Which approach do you take? Let me know in the comments below!
Snacks is the best of Ember Octane in a highly digestible monthly newsletter. (No spam. EVER.)