Events vs Actions in Ember.js

jimsy

James Harton

Posted on January 28, 2019

Events vs Actions in Ember.js

Recently I was working with some of my team on an Ember component which needed to react to JavaScript events they expressed some confusion about the difference between JavaScript events and Ember's Action system. I decided to write up the basics here.

Blowing bubbles

One of the fundamental behaviours of JavaScript DOM events is bubbling. Let's focus on a click event, although the type of event is arbitrary. Suppose we have an HTML page composed like this:

<html>
<body>
  <main>
    <p>Is TimeCop a better time travel movie than Back To The Future?</p>
    <button>Yes</button>
    <button>No</button>
    <button>Tough Call</button>
  </main>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Supposing I load this page in my browser and I click on the "Tough Call" button (one of three correct answers on this page) then the browser walks down the DOM to find the element under the mouse pointer. It looks at the root element, checks if the coordinates of the click event are within that element's area, if so it iterates the element's children repeating the test until it finds an element that contains the event coordinates and has no children. In our case it's the last button element on the screen.

Once the browser has identified the element being clicked it then checks to see if it has any click event listeners. These can be added by using the onclick HTML attribute (discouraged), setting the onclick property of the element object (also discouraged) or by using the element's addEventListener method. If there are event handlers present on the element they are called, one by one, until one of the handlers tells the event to stop propagating, the event is cancelled or we run out of event handlers. The browser then moves on to the element's parent and repeats the process until either the event is cancelled or we run out of parent elements.

Getting a handle on it

Event handlers are simple javascript functions which accept a single Event argument (except for onerror which gets additional arguments). MDN's Event Handlers Documentation is very thorough, you should read it.

There are some tricky factors involving the return value of the function; the rule of thumb is that if you want to cancel the event return true otherwise return nothing at all. The beforeunload and error handlers are the exception to this rule.

A little less conversation

Ember actions are similar in concept to events, and are triggered by events (click by default) but they propagate in a different way. The first rule of Ember is "data down, actions up". What this means is that data comes "down" from the routes (via their model hooks) through the controller and into the view. The view emits actions which bubble back "up" through the controller to the routes.

Let's look at a simple example. First the router:

import Router from '@ember/routing/router';

Router.map(function() {
  this.route('quiz', { path: '/quiz/:slug'})
});

export default Router;
Enter fullscreen mode Exit fullscreen mode

Now our quiz route:

import Route from '@ember/routing/route';

export default Route.extend({
  model({ slug }) {
    return fetch(`/api/quizzes/${slug}`)
      .then(response => response.json());
  }
});
Enter fullscreen mode Exit fullscreen mode

Now our quiz template:

<p>{{model.question}}</p>
{{#each model.answers as |answer|}}
  <button {{action 'selectAnswer' answer}}>{{answer}}</button>
{{/each}}
Enter fullscreen mode Exit fullscreen mode

A quick aside about routing

When we load our quiz page Ember first enters the application route and calls it's model hook. Since we haven't defined an application route in our app Ember generates a default one for us which returns nothing from it's model hook. Presuming we entered the /quiz/time-travel-movies URI the router will then enter the quiz route and call the model hook which we presume returns a JSON representation of our quiz. This means that both the application and the quiz route are "active" at the same time. This is a pretty powerful feature of Ember, especially once routes start being deeply nested.

More bubble blowing

When an action is fired Ember bubbles it up the chain; first to the quiz controller, then to the quiz route and then to the parent route and so on until it either finds an action handler or it reaches the application route. This bubbling behaviour is pretty cool because it means we can handle common actions near the top of the route tree (log in or out actions for example) and more specific ones in the places they're needed.

Notably Ember will throw an error if you don't have a handler for an action, so in our example above it will explode because we don't handle our selectAnswer in the controller or the route.

The lonesome component

Ember's "data down, actions up" motto breaks down at the component level. Ember components are supposed to be atomic units of UI state which don't leak side effects. This means that our options for emitting actions out of components are deliberately limited. Actions do behave exactly as you'd expect within a component, except that there's no bubbling behaviour. This means that actions that are specified within a component's template which do not have a corresponding definition in the component's javascript will cause Ember to throw an error.

The main way to allow components to emit actions is to use what ember calls "closure actions" to pass in your action as a callable function on a known property of your component, for example:

{{my-button onSelect=(action 'selectAnswer' answer) label=answer}}
Enter fullscreen mode Exit fullscreen mode
import Component from '@ember/component';
import { resolve } from 'rsvp';

export default Component({
  tagName: 'button',
  onSelect: resolve,

  actions: {
    selectAnswer(answer) {
      return this.onSelect(answer);
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

This is particularly good because you can reuse the component in other places without having to modify it for new use cases. This idea is an adaptation of the dependency injection pattern.

The eventual component

There are three main ways components can respond to browser events. The simplest is to use the action handlebars helper to respond to your specific event, for example:

<div {{action 'mouseDidEnter' on='mouseEnter'}} {{action 'mouseDidLeave' on='mouseLeave'}}>
  {{if mouseIsIn 'mouse in' 'mouse out'}}
</div>
Enter fullscreen mode Exit fullscreen mode

As you can see, this can be a bit unwieldy when responding to lots of different events. It also doesn't work great if you want your whole component to react to events, not just elements within it.

The second way to have your component respond to events is to define callbacks in your component. This is done by defining a method on the component with the name of the event you wish to handle. Bummer if you wanted to have a property named click or submit. There's two things you need to know about Component event handlers; their names are camelised (full list here) and the return types are normalised. Return false if you want to cancel the event. Returning anything else has no effect.

import Component from '@ember/component';

export default Component({
  mouseIsIn: false,

  mouseDidEnter(event) {
    this.set('mouseIsIn', true);
    return false;
  },

  mouseDidLeave(event) {
    this.set('mouseIsIn', false);
    return false;
  }
});
Enter fullscreen mode Exit fullscreen mode

The third way is to use the didInsertElement and willDestroyElement component lifecycle callbacks to manually manage your events when the component is inserted and removed from the DOM.

export default Component({
  mouseIsIn: false,

  didInsertElement() {
    this.onMouseEnter = () => { this.set('mouseIsIn', true); };
    this.onMouseLeave = () => { this.set('mouseIsIn', false); };
    this.element.addEventListener('mouseenter', this.onMouseEnter);
    this.element.addEventListener('mouseleave', this.onMouseLeave);
  },

  willRemoveElement() {
    this.element.removeEventListener('mouseenter', this.onMouseEnter);
    this.element.removeEventListener('mouseleave', this.onMouseLeave);
  }
});
Enter fullscreen mode Exit fullscreen mode

Note that using either of the last two methods you can use this.send(actionName, ...arguments) to trigger events on your component if you think that's cleaner.

Conclusion

As you can see, actions and events are similar but different. At the most basic level events are used to make changes to UI state and actions are used to make changes to application state. As usual that's not a hard and fast rule, so when asking yourself whether you should use events or actions, as with all other engineering questions, the correct answer is "it depends".

💖 💪 🙅 🚩
jimsy
James Harton

Posted on January 28, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related