When to use computed properties on models


by Ryan Toronto

When to use computed properties on models

Computed properties are one of the best features of the Ember Object model. They give us an efficient and conventional way to derive state from other properties, which is something that every UI application needs.

The fullname property is the "Hello world" of computed properties:

// app/models/user.js
export default DS.Model.extend({
  firstname: DS.attr('string'),
  lastname: DS.attr('string'),

  fullname: Ember.computed('firstname', 'lastname', function() {
    return `${this.get('firstname')} ${this.get('lastname')}`;
  })
});

There aren't many constraints around computed properties in Ember. Since they're part of the object model, we can use them pretty much anywhere: routes, models, components, controllers, and even helpers.

Today we'll take a look at when we should use computed properties in the model layer, and when we're better off moving that logic somewhere else.

What's Ember's model layer for?

First, let's recap. Ember's model layer is all about persistent data. This is data that's expected to be shared between the active client, any other clients, and the server. Think of a Post with ID 123: it has a title, author, and text, and we'd expect these attributes to be the same for any user of the application.

So, the first sign that a computed property belongs on a model is if it's transforming data that's shared between all clients.

Let's jump back to our Post model. Posts have many comments, and each comment has an author. If we want to know who has commented on a post, we can define a new computed property called commenters directly on our Post:

// app/models/post.js
export default DS.Model.extend({
  title: DS.attr('string'),
  text: DS.attr('string'),
  author: DS.belongsTo('author'),
  comments: DS.hasMany('comments'),

  commenters: Ember.computed('comments.@each.author', function() {
    return this.get('comments').mapBy('author').uniq();
  })
});

Now, post.get('commenters') will give us all the users that have commented on this post.

Computed properties in the model layer are great at mapping related properties from relationships. Our example above is only concerned with persistent shared data, so the Post model ends up being the perfect place to put this type of computed property.

Separating persistent and transient state

Now let's look at an example of a computed property in a model that's a bad fit. Let's say we want to display a message if the current user has commented on this post:

{{#if post.currentUserDidComment}}
  Thanks for commenting on this post!
{{/if}}

We could create the following computed property:

// app/models/post.js
export default DS.Model.extend({
  session: Ember.inject.service(),

  comments: DS.hasMany('comments'),
  commenters: Ember.computed('comments.@each.author', function() {
    return this.get('comments').mapBy('author').uniq();
  }),

  currentUserDidComment: Ember.computed('commenters.[]', 'session.currentUser', function() {
    let commenters = this.get('commenters');
    let currentUser = this.get('session.currentUser');

    return commenters.includes(currentUser);
  }),
});

However, a computed property in the model is the wrong approach for solving this problem.

The fact that we are injecting the session service into our model is a giveaway that our model layer has stepped outside the realm of persistent data. I've found that in general, a good rule to follow is to never inject application services into models, since most application services are written to handle transient state.

So how do we solve the problem of displaying a message if the current user commented on the post? A component or some template code using ember composable helpers can get this job done nicely.

{{#if (contains currentUser post.commenters)}}
  Thanks for commenting!
{{/if}}

Formatting data for display

Another type of computed property that I avoid defining on models is properties that involve transforming data for display purposes. Models should be focused on server data. Instead, components and helpers are better suited for displaying data on account of their lifecycles and UI primitives.

Here's an example of a computed property made for display purposes. Let's say we want to show the names of everyone who's commented on a post:

{{! This post has 5 comments from Ryan, Sam, and John. }}
This post has {{post.comments.length}} comments from: {{post.commenterNames}}.

We could write the commenterNames computed property to generate a list of names like this:

// app/models/post.js
export default DS.Model.extend({
  // ...
  comments: DS.hasMany('comments'),
  commenters: Ember.computed('comments.@each.author', function() {
    return this.get('comments').mapBy('author').uniq();
  }),
  commenterNames: Ember.computed('commenters.@each.fullname', function() {
    let commenters = this.get('commenters');
    let names = commenters.mapBy('fullname');
    // add an "and" before the last name.
    names.replace(names.get('length') - 1, 1, [`and ${names.get('lastObject'}`]);

    return names.join(', ');
  })
});

Here we're mixing a pure data transform (grabbing a list of names) and a display transform (inserting an "and" and joining the list with commas). This mixing of concerns can lead to several problems:

  1. What if we need to truncate the list of names based on screen size? To do this we'd need to change where the "and" is inserted, which would involve accessing the screen size. But now our model layer would be dealing with non-persistent state.

  2. What if we need to display this in another language? The model would need access to the current user's preferred language and the ability to translate strings in that language. Again, this falls outside of persistent state.

  3. What about customizing the delimiter? If we ever wanted to use a character other than a comma, there's no easy way to do this. The comma is hard-coded in the computed property.

Because of these problems, I avoid writing computed properties in models that are specific to the display of data. In this example, a model computed property that creates a list of commenter names is fine, since it's a pure data transform. But then I would then use a component to handle the display of those names:

export default DS.Model.extend({
  comments: DS.hasMany('comments'),
  commenters: Ember.computed('comments.@each.author', function() {
    return this.get('comments').mapBy('author').uniq();
  }),
  commenterNames: Ember.mapBy('commenters', 'fullname')
});
{{display-items items=post.commenterNames}}

The display-items component can then take care of any concerns for truncation, screen size, or translation. This allows us to separate how we generate our list of commenter names from how we display them. We might even find other uses for our display-items component elsewhere in our system.

Summary

In closing,

  • Use computed properties on models to transform persistent data.
  • Avoid computed properties on models that interact with transient data, like services.
  • Avoid computed properties on models that transform data for display purposes.
  • Use components and helpers when dealing with transforms that rely on transient state or that are concerned with display logic.

Questions?

Send us a tweet:

Or ask us in Inside EmberMap, our private Slack workspace for subscribers.