Observable path patterns in Ember

26 March 2014

Property paths are the heart and soul of Ember.js apps. You use them in templates and you define dependencies for computed properties and observers through property paths.

In this post, I concentrate on this latter and show you various ways of setting up these dependencies through a practical example. There is a pretty good section in the guides about one path pattern. Here, I intend to cover more (all?) of them.

Badges are back

I am going to build on the badges "micro-app" that I had started to develop in my previous post about getters in setters.

There are two minimal model classes, User and Badge:

 1App.User  = Ember.Object.extend({
 2  name: ''
 3});
 4
 5App.Badge = Ember.Object.extend({
 6  name: '',
 7  score: 0,
 8  unlocked: false
 9});

We also create a couple of instances to have some data to show and work with:

 1var cory = App.User.create({ name: "Cory Filibuster" });
 2
 3var rook = App.Badge.create({
 4  name: "R00k",
 5  score: 1,
 6  unlocked: true
 7});
 8
 9var taciturn = App.Badge.create({
10  name: "Taciturn",
11  score: 10
12});
13
14var talkative = App.Badge.create({
15  name: "Talkative",
16  score: 100
17});
18
19var hemingway = App.Badge.create({
20  name: "Hemingway",
21  score: 1000
22});

Our application looks like this initally:

Initital state

Simple property chain

The simplest, non-aggregate property path is just a series of names, connected by dots. This designates a property that you can arrive at by walking the path, segment by segment, where each of them gets you to another object until you finally reach the property.

(If a path is very long, you should probably think about the dependencies between your objects and the structure of your code.)

You see that the profile panel has the user's first name as its header. The property that gets displayed there can be defined by such a path:

1App.IndexController = Ember.ArrayController.extend({
2  (...)
3  firstName: function() {
4    return this.get('user.name').split(/\s+/)[0];
5  }.property('user.name'),
6});

This sets up a computed property (CP) that will be recomputed whenever user.name changes. The arguments to the property call are called the dependent keys of the CP and you can pass as many as you would like (although, thanks to the various property path patterns, you will rarely need a lot).

Now, whenever the name property of the user property on the controller changes, firstName is recomputed and this change gets propagated to all the instances where firstName is used (e.g in the header of the panel).

Above that, the user.name key also triggers a change if the user object itself changes. To see that, we turn to the thing you should only ever use for demo purposes, the __container__ object:

1var maggie = App.User.create({ name: "Maggie Raindance" });
2App.__container__.lookup('controller:index').set('user', maggie);

You can see the name change in the header right away:

User name changes

Aggregate property paths

On other occasions, a CP should depend on an array of items. Whenever something gets added to or removed from the array, the property needs to be updated.

One example of that is the number of badges in the profile panel:

1App.IndexController = Ember.ArrayController.extend({
2  (...)
3  badgeCount: function() {
4    return this.get('model').length;
5  }.property('model.[]'),
6});

The model here is the array of badges so when we add another one through the New badge panel, badgeCount gets its new value:

Badge count gets updated

What I said about the user.name path triggering an update when the user changes also holds true here. If the array of badges was swapped out for another array, it would trigger the recalculation of badgeCount.

Aggregate property path with a specified property

There are cases where the value of the CP becomes stale also when the items in the dependent array stay the same, but a certain property of one of them changes. Ember has a way to express this very succintly.

The example is the "Total score" in the profile panel:

1App.IndexController = Ember.ArrayController.extend({
2  (...)
3  totalScore: function() {
4    var sum = function(s1, s2) { return s1 + s2; };
5    return this.get('model').getEach('score').reduce(sum);
6  }.property('[email protected]'),
7});

This is the most inclusive of the patterns we have seen so far. It prompts an update if the model changes, if any item is added or removed and also if the score of any item changes. If we type this at the console:

1App.__container__.lookup('controller:index').set('model.lastObject.score', 200);

, then the total score changes accordingly, even though no item was inserted or deleted:

Total score

Brace yourself

To present the next pattern, let's assume that not all badge scores need to be tallied to get the total but only the unlocked ones (which makes total sense). So the dependent keys for totalScore needs to account for that. That's pretty easy:

1App.IndexController = Ember.ArrayController.extend({
2  (...)
3  totalScore: function() {
4    var sum = function(s1, s2) { return s1 + s2; };
5    return this.get('model').filterBy('unlocked').getEach('score').reduce(sum);
6  }.property('[email protected]', '[email protected]'),
7});

When the second badge is unlocked, the score jumps from 1 to 11 (and the number of badges from 1 to 2), so the dependent keys work fine:

1App.__container__.lookup('controller:index').get('model').objectAt(1).set('unlocked', true);

Unlocked property change triggers update

Starting with Ember 1.4.0, though, there is a more concise way to define the same, called "property brace expansion". It works very similar to argument expansion in the shell:

1App.IndexController = Ember.ArrayController.extend({
2  (...)
3  totalScore: function() {
4    var sum = function(s1, s2) { return s1 + s2; };
5    return this.get('model').filterBy('unlocked').getEach('score').reduce(sum);
6  }.property('[email protected]{score,unlocked}'),
7});

This establishes that totalScore should be recomputed if either the score or unlocked properties of any item in the model array changes.

An important restriction of property brace expansion is that the expansion part can only be placed at the end of the path, so e.g property('{foo,bar}.baz') will not have the desired effect.

Computed property macros are the bee's knees

Computed property macros have several advantages. They are very expressive, very performant and perhaps most importantly more robust than typing out the property path patterns by hand where a typo can cause a considerable amount of head-scratching.

They are also a joy to work with and compose. In fact, all the CP definitions above can be re-defined by using only macros:

1App.IndexController = Ember.ArrayController.extend({
2  (...)
3  badgeCount: Ember.computed.alias('unlockedBadges.length'),
4  unlockedBadges: Ember.computed.filterBy('model', 'unlocked'),
5  unlockedScores: Ember.computed.mapBy('unlockedBadges', 'score'),
6  totalScore: Ember.computed.sum('unlockedScores'),
7});

They have one big disadvantage, though. It is very hard to use them in a blog post to explain property path patterns.

(The code presented here can be found as a gist on Github)

ps. Yes, that Maggie Raindance.

Share on Twitter