Events vs Actions in Ember.js
James Harton
Posted on January 28, 2019
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>
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;
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());
}
});
Now our quiz template:
<p>{{model.question}}</p>
{{#each model.answers as |answer|}}
<button {{action 'selectAnswer' answer}}>{{answer}}</button>
{{/each}}
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}}
import Component from '@ember/component';
import { resolve } from 'rsvp';
export default Component({
tagName: 'button',
onSelect: resolve,
actions: {
selectAnswer(answer) {
return this.onSelect(answer);
}
}
});
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>
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;
}
});
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);
}
});
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".
Posted on January 28, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.