Data flow in Ember applications


by Ryan Toronto

Data flow in Ember applications

Data flow might just be the hardest part of writing a stateful client application. Proper data flow can make or break your Ember app, and the key to success is understanding all your options and knowing when each is most appropriate.

Data down

The most straight-forward way of getting data into a component is by passing it during template invocation, better known as data down.

The explicitness is good  --  it tells other developers what they need to know about using your components, since there's no hidden state. Which would you rather debug: {{edit-current-post}} or {{edit-post post=currentPost}}?

Explicitness also keeps your templates self-explanatory. Just looking at a template is enough to get a good idea of what data will be displayed, how components compose with each other, and what's needed for each component to render.

{{! users/index/template.hbs}}

{{#user-list users=newUsers as |user|}}
  {{user-picture user=user}}
{{#new-user-list}}

The downside of this approach is that as you develop your application, there's a tendency to pass data to some components just so that it can be passed to other components. This creates a Middleman problem  -- you don't know if a component is being passed data because it actually needs it, or because a nested component needs it. The explicitness is lost.

This video outlines some problems that come from Middleman components, and discusses some possible solutions.

Injection

One way to avoid needlessly passing data around your entire UI hierarchy is by using Ember's excellent dependency injection system.

Let's say a component deep in your UI tree needs access to the Ember Data store. Rather than passing it through every component, you can give the nested component a direct reference to the store by using Ember.inject.service():

Injection is commonly used for sharing singleton objects, especially those that represent application-wide state not tied to any particular UI component. For example, most of my Ember applications have a currentUser service that any component can access via injection. Here's how my navigation menu uses this service:

// components/nav-header/component.js

import Ember from 'ember';

export default Ember.Component.extend({
  currentUser: Ember.inject.service()
})
{{! components/nav-header/template.hbs }}

<div>
  Hello {{currentUser.fullName}}!
</div>

Be careful with services and injection. A few services are fine, but if you find yourself representing every object in your domain as a service, you've gone too far. A question I like to ask myself is, would I consider the thing this service represents a global variable? If yes, go ahead and create the service.

Domain modeling

Accessing data by traversing your model graph can dramatically simplify your component hierarchy, and is particularly appropriate for domain-specific components. If you write a component to show a blog post's comments, pass the post in and let the component figure out how to access and display those comments.

{{! post/template.hbs }}

{{post-comments post=model}}
{{! components/post-comments/template.hbs }}

{{#each post.comments as |comment|}}
  {{comment.text}}
{{/each}}

The reasoning here comes from the object-oriented design principle of dependency direction. Sandi Metz summarizes this principle quite nicely:

If you were to give [your objects] advice about how to behave, you would tell them to depend on things that change less often than you do.

So, the logic here is that our domain model  -- the fact that a post has many comments — is not likely to change that often, whereas our UI's representation of those comments is. It's easy to think about what changes we might want to make to the {{post-comments}} component: maybe we want to access author information from the post, or we might disable commenting after a certain period of time passes from the post's publication date. Regardless of what we end up changing, the point is that it often makes sense to encapsulate domain knowledge within a component, making it more isolated and easy to change, and minimizing its external dependencies down to a single model or object.

Additionally, it's good to think about how your data is modeled apart from any specific interface. UIs are often the most complex representations of your data. If you think first about how things are related before jumping into interface development, you'll develop a good mental model for implementing those UIs later.

The biggest tradeoff when allowing components to traverse your domain is that the {{post-comments}} component is no longer reusable for other models.

Imports

JavaScript's import statement is a fantastic and often overlooked way to pull data and functions into components.

Say you have a component that needs to find the aspect ratio of an image. Simply import that function, and you're off to the races:

// image-functions.js

export function getAspectRatio(url) {
  let img = new Image();
  let promise = new Ember.RSVP.Promise((resolve, reject) => {
    img.onload = function() {
      // calculate aspect ratio...
    };

    img.onerror = reject;
  });

  img.src = url;

  return promise;
}
// components/image-info.js

import Ember from 'ember';
import getAspectRatio from 'image-functions';

export default Ember.Component.extend({
  didReceiveAttrs() {
    this._super(...arguments);
    getAspectRatio(this.get('imageUrl'))
      .then((aspectRation) => {
        // do something with the aspect ratio...
      });
  }
});

In general, imports work best for stateless functions that perform some sort of transformation on your data. If a function needs to access anything that cannot be passed in via its arguments, you might need to use some other approach.

Configuration files

Components sometimes need to know about global configuration data.

Imagine working on a component that polls the backend for new data. This component should run in all environments expect for test. An easy way to share this data is by importing the environment config:

// config/environment.js

module.exports = function(environment) {
  // ...

  if (environment === 'test') {
    ENV.allowPolling = false;
  } else {
    ENV.allowPolling = true;
  }

  // ...
};
import Ember from 'ember';
import config from 'my-app/config/environment';

export default Ember.Component.extend({
  startPolling: Ember.on('didInsertElement', function() {
    if (config.allowPolling) {
      // start polling the backend...
    }
  })
})

Try not to litter your config with non-global options or data. It's most appropriate for data that depends on either the run-time or build-time environment of your application.

Yielding

Think of Ember's yield statement as the opposite of data down: it allows a component to pass data back up to it's invoker.

Say you're tasked with building a modal, and a requirement is that the caller needs to be able to customize how the close button is displayed. HTML and CSS are perfect for positioning, so it makes sense to treat this styling option as template code.

To achieve this, we'll have the modal component yield a close-button that can be positioned from within the caller's template:

{{! components/my-modal/template.hbs }}

{{yield (hash
  close-button=(component "modal/close-button" close=(action "close"))
  body=(component "modal/body-content")
)}}
{{! index/template.hbs }}

{{#my-modal as |modal|}}

  {{! we can put the close button anywhere we want! }}
  {{modal.close-button}}

  {{#modal.body}}
    We get to position the modal elements however we want
    in our template!
  {{/modal.body}}

{{/my-modal}}

Using yield allows us to pre-wire the action so that the caller doesn't have to reimplement the close button's behavior. We now have a flexible modal component that can be easily reused throughout our application.

Yield is a great way to share data between components, but it should only be used when you need to expose a new API. Expanding a component's API puts additional responsibility on callers, and should only be done when it makes sense to transfer that burden.

Wrapping up

So, how do you know when to use each approach? Unfortunately, the best answer here is experience. Ember tries hard to nudge you in the right direction, so if something you're doing feels really painful  --  too many Middlemen components, long lists of arguments, a proliferation of services  -- there's a chance there's a better method. Follow your instincts, talk with coworkers, and ask others in the community for a second opinion, and you'll be on your way.

Questions?

Send us a tweet:

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