Deep Dive on Ember Events
The difference between Ember actions and DOM events and why it matters, plus a really rad flowchart.
August 08, 2017 • 2731 words • 9 minute read • Read on Medium
Also available as a recorded talk from EmberConf 2018. Learn more about this talk or the general JavaScript events talk.
Disclaimer: This blog post specifically references Ember 2.14. Some information and code samples may be out of date!
A few days ago, I was working on a really exciting new feature. As part of rolling out these changes, I implemented an onboarding tour — a sequence of tooltips that teach users how to interact with the different parts.
I made one small change to a template in one of our Ember apps, and everything broke. Can you guess what happened?
Before: Clicking on “Next Step of the Tour” opened The Thing.
<div class="cool-new-button" {{action "toggleTheThing"}}>
{{#if shouldShowThisTooltip}}
<div class="tour-tooltip">
This button can open and close The Thing!
<a {{action "toggleTheThingAndAdvanceToNextStep" bubbles=false}}>
Next Step of the Tour
</a>
</div>
{{/if}}
</div>
After: Clicking on “Next Step of the Tour” opened The Thing and immediately closed it — so fast that it looked like nothing happened.
<div class="cool-new-button" onClick={{action "toggleTheThing"}}>
{{#if shouldShowThisTooltip}}
<div class="tour-tooltip">
This button can open and close The Thing!
<a {{action "toggleTheThingAndAdvanceToNextStep" bubbles=false}}>
Next Step of the Tour
</a>
</div>
{{/if}}
</div>
The Only Change: <div {{action "toggleTheThing"}}>
became <div onClick={{action "toggleTheThing"}}>
Me: um, what?
So off I went to the debugging races, and with a little bit of work came to the conclusion that despite their visual similarity, {{action "foo"}}
and onClick={{action "foo"}}
represent completely different ways of listening for clicks.
Using onClick={{action "foo"}}
listens for DOM events that the browser sends directly, whereas {{action "foo"}}
listens for actions fired by Ember in response to browser events. I could tell these two types of event listeners had subtly different behavior, but couldn’t yet articulate what or why.
I realized that I was just scratching the surface of how events are handled in Ember and there was a lot more to learn.
I spent the next three days staring at my computer screen, muttering on my walks home from work, and dancing in my kitchen when I finally managed to get a comprehensive understanding of how DOM events and Ember actions fit together.
This is what I’ve learned!
Interactive Demo: Events in Ember
If you’re a visual person and want to get your hands on some code to explore these ideas for yourself, I built a handy Ember Twiddle demo that explains three common ways of listening to events in Ember and the way they interact.
If you’d prefer to get a clear overview of everything before you try out the demo, keep reading!
The Basics
Let’s start with a few definitions to make sure we’re all on the same page.
DOM: Document Object Model, an API that describes how web pages work — how HTML is rendered on a page, what events happen as users interact with that page, and more. Browsers like Chrome, Firefox, and Safari implement this API in order to display websites. (For more info, check out this DOM documentation.)
DOM node: A single element in the DOM. For example, a <div>
is a single DOM node. Nodes can have parents and children:
<div id="parent"> <!-- This is a node. -->
<button id="child"></button> <!-- This is also a node. -->
</div>
DOM event: A standard way of describing that something has happened on a page. This includes things like clicks, keyboard presses, form submissions, and dragging elements around the screen. (You can see the full list of DOM events in this Event reference.)
You can add an event listener to any DOM node that should do something when a particular event happens to it; for example, you might care when a button is clicked or when a user types inside a text field. The listener is a JavaScript function that is called by the browser whenever that event happens on the specified node. The listener is passed an Event
object with relevant information when it is called.
By default, browsers call the listener for the target node of the event and then call the event listener of every parent of that node — a process called propagation. This is helpful; say you have a button with an icon and some text inside it. If a user clicks exactly on the icon, you still want the button to receive the click. However, any node in the chain can override this behavior and stop propagation in its listener to prevent its ancestors from firing their listeners— ensuring that nothing else happens as a result of that event.
Ember action: An Ember-specific abstraction on top of DOM events. Ember actions are also functions at their heart, but have access to extra application context and logic (like attributes or functions defined in the associated controller or component). Ember actions are fired as a result of DOM events, but are called by the framework — not the browser — and may or may not have access to the original DOM event that caused them.
Order of Events in Ember
When a user clicks on your fancy new button, how exactly does a function defined in your component get fired? What happens if a previous action calls stopPropagation()
on the event, or another action has set bubbles=false
?
The basic overview is:
-
A DOM event is created
-
All the native DOM event listeners are fired, starting from the target node and walking up the tree — unless one of those stops propagation
-
All the Ember action listeners are fired, starting from the target node and walking up the tree — unless one of those stops propagation
The full(er) overview is explained in this super rad flowchart I made (the brainchild of previously referenced kitchen dance).
Pro tip: all the colors in this chart match the colors used for different event listeners in the Ember Twiddle demo, so cross-reference at your leisure!
Types of Event Listeners in Ember
How do you know whether you’re using a DOM event listener or an Ember action listener? When will your action have access to the original DOM event? Can your action prevent other actions from being fired?
There are three main ways of adding listeners in Ember:
- Adding an Ember action listener to a component with an event name attribute.
{{some-component click=(action “handleClick”)}}
2. Adding an Ember action listener to a DOM node by modifying the element with the action
helper.
<div {{action "handleClick"}}></div>
<div {{action "handleDoubleClick" on="doubleClick"}}></div>
3. Adding a DOM event listener to a DOM node by using an event HTML attribute.
<div onclick={{action "handleClick"}}></div>
(Pro tip: this is a great time to check out the Ember Twiddle demo if you haven’t yet! You can see for yourself how different combinations of these events bubble from children to parents, and modify the source code that runs the demo to explore further.)
1. Component Event Attributes
Listener Type: Ember action
Access to original DOM event: Yes
Supported events: defined by Ember.Component
Can it stop propagation:
- Yes for other Ember actions, because those are fired after this action.1
- No for DOM events, because those are fired before this action.
Code examples for click Attribute actions. Link to examples in GitHub Gist.
2. Element Modifiers
Listener Type: Ember action
Access to original DOM event: No
Supported events: defined by Ember.Templates.helpers
Can it stop propagation:
- Yes for other Ember actions, because those are fired after this action.2
- No for DOM events, because those are fired before this action.
Code examples for Element Modifier actions. Link to examples in GitHub Gist.
3. DOM Event Attributes
Listener Type: DOM Event
Access to original DOM event: Yes
Supported events: defined by DOM API
Can it stop propagation:
Code examples for onClick Attribute actions. Link to examples in GitHub Gist.
Learnings and Recommendations
All of this may be interesting, but how does it relate to the bug that originally led me down this rabbit hole?
And what does this mean for you when you’re handling events in Ember?
Let’s take another look at that buggy code example:
<div class="cool-new-button" onClick={{action "toggleTheThing"}}>
{{#if shouldShowThisTooltip}}
<div class="tour-tooltip">
This button can open and close The Thing!
<a {{action "toggleTheThingAndAdvanceToNextStep" bubbles=false}}>
Next Step of the Tour
</a>
</div>
{{/if}}
</div>
We’ve got a child node that is using an Ember action listener to toggleTheThingAndAdvanceToTheNextStep
. When it handles the Ember event, it stops any other Ember events from firing (thanks to bubbles=false
). This seems like it should prevent the parent’s action from being fired.
However—before the child’s action handler is ever called, the DOM event listener (toggleTheThing
) of its parent node is called. The child hasn’t had a chance yet to stop propagation. The parent action toggles The Thing open, and the child action later toggles it back shut.
In this example, events would fire in this order (provided no handlers stop propagation):
- DOM events on
<a>
- DOM events on
<div class="tour-tooltip">
- DOM events on
<div class="cool-new-button">
(opens The Thing) - Ember actions on
<a>
(closes The Thing) - Ember actions on
<div class="tour-tooltip">
- Ember actions on
<div class="cool-new-button">
Takeaways
-
DOM events always fire before Ember actions.
-
Attaching actions directly to DOM event attributes (like
onclick
) uses the browser’s DOM events API directly. -
Attaching actions to Ember attributes (like a component’s
click
attribute or modifying an element with theaction
helper) uses Ember’s actions API, an abstraction on top of the DOM events API. -
You can use
bubbles=false
to stop an event from propagating only if you are using the action template helper in regular form not in closure form (e.g.{{action "foo" bubbles=false}}
works butclick=(action "foo" bubbles=false)
does not). -
You can use
event.stopPropagation()
to stop an event from propagating only if you are using a handler that has access to the original DOM event — either by using an HTML event attribute likeonclick
or by using a component event attribute likeclick
. -
Calling
event.stopPropagation()
on a DOM event handler will stop any Ember actions from firing because of that event — risky business! -
For consistency and to prevent subtle bugs, I recommend always using Ember actions over DOM events.
One possible exception to always using Ember actions: if you want to optionally add an event to a DOM node. Currently, Ember template helpers do not support modifying elements with an inline conditional:
<div {{if shouldRespondToClick (action "handleClick")}}></div>
Since that’s not valid Handlebars, you have to do this instead:
<div onClick={{if shouldRespondToClick (action "handleClick")}}</div>
Alternatively, you can perform this logic check in your action handler and return early: if (!this.get('shouldRespondToClick')) { return; }
. However, your element will have styling associated with click-ability, so you may also need to optionally add a style that sets cursor: default
.
That’s a lot of work just to keep events more consistent; it’s up to you to decide which approach you prefer.
The biggest takeaway though: now you know how events in Ember work! And you can make informed decisions and debug with confidence.
Commence kitchen dancing, or in the words of my personal favorite Slack emoji, :corgi: on.
P.S. Do you love nerding out about software and learning about technology? You should join us at Square so we can learn and build great products together!
-
You cannot pass
bubbles=false
as part of a closure action hash (likeclick=(action "foo" bubbles=false)
). If you want to stop propagation, you have to callevent.stopPropagation()
in your handler. See this GitHub issue or the documentation for the action helper for more information. ↩ -
This type of action doesn’t have access to the original DOM event so it cannot call
event.stopPropagation()
. If you want to stop propagation, you have to setbubbles=false
on the action helper. ↩ -
Setting
bubbles=false
on the action helper doesn’t actually stop propagation for DOM events — that’s an Ember-specific abstraction. If you want to stop propagation, you have to callevent.stopPropagation()
in your handler. ↩ -
This is where it gets extra funky — calling
event.stopPropagation()
will prevent any Ember actions from firing because of that event — even child actions, which totally goes against normal DOM propagation order but which you can see in action in this Ember Twiddle. Why? It’s because callingstopPropagation
at this point prevents the event from bubbling up to the special<div id="root">
node that is the parent node of everything inside an Ember app. That node’s handler is what kicks off the process of firing Ember actions — see the flowchart above if this is still confusing! ↩
Slides and speaker notes from a talk originally given at AlterConf in Portland on October 1, 2016.
Learn how to accurately and effectively advocate for yourself and grow in your career by recording your accomplishments.