Leveraging Computed Properties in EmberJS
<p>Modern-day applications often consist of complex data layers, backed with restful APIs and/or complex back-end systems to provide the services. Fortunately for us, it's very simple today to create such applications using modern fronting framework and tooling. Frameworks like AngularJS, EmberJS, Aurelia, amongst many other frameworks, have demonstrated that the data logic can be extracted to a simpler layer, letting the user deal with transactions, and not deal with API configuration. Building an application with this approach can often yield to very high development velocities, at the risk of ...</p>
Modern-day applications often consist of complex data layers, backed with restful APIs and/or complex back-end systems to provide the services. Fortunately for us, it's very simple today to create such applications using modern fronting framework and tooling. Frameworks like AngularJS, EmberJS, Aurelia, amongst many other frameworks, have demonstrated that the data logic can be extracted to a simpler layer, letting the user deal with transactions, and not deal with API configuration. Building an application with this approach can often yield to very high development velocities, at the risk of increasing technical debt; frameworks like these greatly simplify tasks, but require the entire development team to understand the given framework. In many cases, using a complex framework can result in a lot of confusion, given that resources are often disparate, or even nonexistent. Such is the case, I found, for ember data, a library to simplify data transactions using EmberJS.
In this write-up, I hope to show you how to leverage Ember Data's computed properties. Understanding how computed properties work in ember is key to simplifying your application. By having data layer which performs most operations, you can easily avoid groundwork for initialization of their models.
A refresher on computed properties
A computed property is a property which is computed in real-time. Computed properties are not sent the server, when the model was saved, deleted, or modified. For this reason, it is important to understand that not everything should be a computed property. In most cases, it's best to simplify the data layer as much as possible, without restricting performance. (Recall that computed properties are dependent on key values, and therefore can cause a lot of performance issues, and headaches when tracing performance).
The canonical example of a computed property is creating someone's full name. As you are well aware, a person's full name is the combination of their first, middle, and last names.
On the official EmberJS website, here is the example they provide:
export default DS.Model.extend({
firstName: DS.attr("string"),
lastName: DS.attr("string"),
fullName: Ember.computed("firstName", "lastName", () => {
return `${this.get("firstName"} ${this.get("lastName")}`;
}
});
Can you see what's wrong? What if the user doesn't have a first name? Suppose we only type in "Renner" in the input
field bound to lastName
. fullName
would simply result in Renner
.
Using notEmpty
to complete the fullName
What we really need is a way to check if both the firstName
and the lastName
are present? Then, if they are, set the fullName
? A naive approach would be to use observers (now depreciated):
export default DS.Model.extend({
firstName: DS.attr("string"),
lastName: DS.attr("string"),
fullName: DS.attr("string"),
namesChanged: => {
if (!Ember.isBlank(this.get("firstName")) && !Ember.isBlank(this.get("lastName"))) {
this.set("fullName", `${this.get("firstName"} ${this.get("lastName")}`);
}
}.observes("firstName", "lastName")
});
By opting for an observer instead of a computed property, we allow fullName
to be sent to the server and saved in the data model, which may or may not be a good thing. In this case, it makes very little sense, however, to have fullName
as part of the data model, as fullName
can always be found by looking at firstName
and lastName
of the given model. Besides, observers suck.
A better solution would be to use Javascript's Array
type to group all the names of a given candidate. This can be done with a combination of Array.splice()
and Array.concat()
, with a safeguard using Ember.makeArray()
:
export default DS.Model.extend({
firstName: DS.attr("string"),
lastName: DS.attr("string"),
names: Ember.computed.group("firstName", "lastName"),
hasFullName: Ember.computed.equal("names.length", 2),
fullName: Ember.computed("firstName", "lastName", () => {
return Ember.makeArray(this.get("names")).splice().concat(" ");
}
});
Using Ember.computed.oneWay
To address some performance issues, users have recently taken refuge in ember's new one-way data binding features. Starting in Ember 2.0, one-way data bindings are default, and the user must explicitly opt in to two-way bindings. Using one-way data binding reduces the complexity of a given computed property, and prevents changes from propagating in the other direction. For example, if we are tracking the length of a given has many relationship, we me opt to do so in the following manner:
models/applicant
:
export default DS.Model.extend({
jobs: DS.hasMany("job"),
hasJobs: Ember.computed.notEmpty("jobs")
});
Using the computed method notEmpty
works as expected. However, this means that Ember's Run
loop must execute the method computed.notEmpty
every time a new job is added to the applicant.
A better solution would be to leverage Ember's oneWay
data binding. This way, we can bind to the length
attribute, without having to worry about propagating changes to the length
attribute of jobs
. Just like in the previous case, using Ember.computed.alias
, we can use an macro key to get the property we are interested:
export default DS.Model.extend({
jobs: DS.hasMany("job"),
hasJobs: Ember.computed.oneWay("jobs.length")
});
Using this approach will be faster because the length
property of jobs
will always return an integer (because deep down, jobs
is an instance of Ember's ArrayProxy
); in the case that the jobs
array is empty, it will return 0
, and 0
in Handlebars evaluates to false
.
Common Ember.Computed
Mistakes
Combining Arrays
In many cases, you might find yourself trying to combine arrays. For example, suppose you're building an application to perform event planning and to manage events such as weddings, corporate events, and private events. In building such an application, you would have to maintain a list of people both going to the events and people not going to the events. For each event, you would typically have a list of all attendees to an event, and a list of all attendees not attending an event, and you might want to combine these attendees. A naiive approach would be to have a property on the given component or controller which represents each group of attendees:
export default Ember.Controller.extend({
attendees: Ember.A(),
attendeesNotGoing: Ember.A()
}
Thus, the logic to add or remove attendees would look like this:
export default Ember.Controller.extend({
attendees: Ember.A(),
attendeesNotGoing: Ember.A(),
actions: {
unRSVP: function(attendee) {
this.get("attendees").removeObject(attendee);
this.get("attendeesNotGoing").pushObject(attendee);
}
}
}
Can you see the problem here? Although this approach will work, maintaining two separate lists of attendees forces us to carry out two separate operations when the add or remove a given attendee. Wouldn't it be easier if we could just add or remove a given attendee in one line?
A better approach would be to use the Ember.Computed.filterBy
computed property helper. In fact, the solution is to not combine arrays at all; instead, we can keep all the attendees in one array, and filter out the attendees which are or aren't going:
export default Ember.Controller.extend({
attendees: Ember.A(),
attendeesNotGoing: Ember.computed.filterBy("attendees", "rsvp", true),
attendeesGoing: Ember.computed.filterBy("attendees", "rsvp", false)
}
Thus, the logic to how to remove a given applicant would only be one line:
export default Ember.Controller.extend({
attendees: Ember.A(),
attendeesNotGoing: Ember.computed.filterBy("attendees", "rsvp", true),
attendeesGoing: Ember.computed.filterBy("attendees", "rsvp", false),
actions: {
unRSVP: function(attendee) {
attendee.set("rsvp", false);
},
rsvp: function(attendee) {
attendee.set("rsvp", true);
}
}
}
But wait! Can you still see that there are some problems? We can further optimize this code and remove both rsvp()
and unRSVP()
actions, replacing them with a single toggleRSVP()
. This is easily done with Ember's toggleProperty
method:
export default Ember.Controller.extend({
attendees: Ember.A(),
attendeesNotGoing: Ember.computed.filterBy("attendees", "rsvp", true),
attendeesGoing: Ember.computed.filterBy("attendees", "rsvp", false),
actions: {
toggleRSVP: function(attendee) {
attendee.toggleProperty("rsvp");
}
}
}
And here, instead of having a separate button for RSVP and for unRSVP, we can simply find the current value of the attendee's status to the template:
templates/components/event.hbs
<table class="event">
<thead>
<tr>
<th>Event Name</th>
<th>Status</th>
<th>Attendees</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{event.name}}</td>
<td>{{event.status}}</td>
<td>
{{#if model.attendeesGoing.length}}
<ul>
{{#each model.attendeesGoing as |attendee|}}
<li>{{attendee.fullName}}</li>
{{/each}}
</ul>
{{/if}}
</td>
</tr>
</tbody>
</table>
Creating Records the Right Way
When I first started using Ember Data, I didn't understand why saving records was so tedious. Using any model that leveraged composition, I would often find myself calling the save()
method several times, within 4 promises deep. Here's an example of one of my save()
methods:
saveVehicles() {
var promises = [];
this.get("currentModel.applicant.vehicles").forEach((vehicle) => {
// only save vehicle loan when `hasLoan` is set
if (vehicle.get("loan")) {
let vehicleLoan = vehicle.get("loan");
if (vehicle.get("isFinanced")) {
vehicleLoan.then((loan) => {
return loan.save();
});
}
else {
vehicleLoan.then((loan) => {
// when the vehicle isn't financed, there's no liability
return loan.destroyRecord();
})
}
promises.push(vehicleLoan);
}
if (vehicle.get("asset")) {
let vehicleAsset = vehicle.get("asset").then((asset) => {
return asset.save();
});
promises.push(vehicleAsset);
}
promises.push(vehicle.save());
});
return Ember.RSVP.all(promises);
},
The reason this got complicated so quickly is because I had dependent models (using the relationships DS.hasMany
, DS.belongsTo
, and so on. In other words, I had to createRecord
the dependent record before creating the parent record.
ES7 Async to the Rescue
One way to deal with the 4 levels deep of promises is to use ES6 generators and the ES2016 (ES7) extended promise await
API. Generators are a new feature in ES6 which allow asynchronous operations with the keyword yield
. At compile-time, Babel (the ES6 transpiler) searches for every declartion of the keyword yield
, and those occurences thereafter act as return points of each succeeded promise that is returned from the function.
Like generators, the await
keyword awaits the result of a given promise, and does not execute the next yield
ed value until it's resolved.
Generators are actually functions in disguise, with the exception that insted of return
, they use yield
, to indicate the success of a promise.
Let's re-write the complex saveVehicles()
method with a generator:
saveVehicles() {
this.get("currentModel.applicant.vehicles").forEach((vehicle) => {
return new Ember.RSVP.Promise((resolve, reject) => {
// only save vehicle loan when `hasLoan` is set
if (vehicle.get("loan")) {
let vehicleLoan = await vehicle.get("loan");
if (vehicle.get("isFinanced")) {
await vehicleLoan.save();
}
else {
await vehicleLoan.destroyRecord();
}
}
if (vehicleLoan.get("asset")) {
let vehicleAsset = await vehicleLoan.get("asset");
await asset.save();
}
});
});
},
// ...
In order to use ES2016 features, we need to tell Babel:
ember-cli-build.js
var EmberApp = require("ember-cli/lib/broccoli/ember-app")
module.exports = function(defaults) {
var app = new EmberApp(defaults, {
babel: {
includePolyfill: true
},
// You might want to disable jshint as it does not support async/await yet.
hinting: false
})
return app.toTree()
}
Then, on your command-line:
npm install --save-dev regenerator
In his blog post, Damian explores usage of ES2016/ES7 in far greater detail, and I highly recommend you read his post for more information.
Addendum: Async Computed Properties
In some cases, you may need to compute values in a model based on other models, as described above in the saveVehicles
example. In these cases, a typical, hacky solution might involve proxying out the promisy value using something like Ember.computed.alias
, and then using a long chain of then
, catch
, and finally
s. For these cases, luckily, I have discovered Ember CLI Dispatch.
Here's a trivial example of ember-cli-dispatch's usage, for getting parameters for a given item:
import Ember from "ember";
import ajax from "ember-ajax";
import computedPromise from "ember-cli-dispatch/utils/computed-promise";
import ENV from "../../config/environment";
export default Ember.Component.extend({
module: null,
metadata: computedPromise("module.id", function() {
return ajax({
type: "GET",
data: this.get("module.id"),
url: ENV.apiEndpoint
})
}, "Fetching metadata"),
);
In this example, the metadata
for the object is only resolved after the component is loaded. The above assumes you've set up your config/environment.js
file with your adapter endpoint.
But again. do you see a problem with this? Why not refactor this to occur within the Model
?
app/models/module.js
:
import DS from "ember-data";
export default DS.Model.extend({
title: DS.attr("string", {defaultValue: "Untitled Module"}),
metadata: DS.hasMany("data")
//...
});
Then, as soon as module.metadata
is referenced (because all relationships are async unless you opt out in Ember 2.0+), Ember will make the async request, only when needed.
But most of all, metadata
should really be a part of the object payload, as specified in the ember data docs.
Altogether, I'm still not convinced there are many use cases for something like Ember CLI Dispatch, as most logic can be done in the model, and lazily computed on demand. In any case, you should now have the resources to cleanly compute, create, and update your Ember Data components.