Rewriting Apps in Ember Octane
Isaac Lee
Posted on December 24, 2019
Originally published on crunchingnumbers.live
Last Friday, Ember 3.15 was dubbed the Octane edition. To see how easy (and fun) writing an Octane app is, I spent the weekend rewriting my apps Ember Animated (v3.8) and Lights Out (v2.18). Let me share what I learned.
If you have tutorials and demo apps, I encourage you to rewrite them in Octane. You can publish both versions to help everyone understand how the programming model in Ember has evolved over time.
1. Ember Animated (3.8 → 3.15)
Between the two, the Ember Animated represents a production app. It features a few complex routes, several components, mocked APIs, and a comprehensive test suite. The problems and joys that I encountered while rewriting, you will likely also.
I didn't use ember-cli-update and codemods because I had somewhat atypical code from trying out Ember Data Storefront and Ember Animated. I figured, by writing a new app from scratch, I would learn Octane faster.
- App: 3.15, 3.8
- Repo: 3.15, 3.8
- Blog post: Animation and Predictable Data Loading in Ember
a. Baby Steps
I found an incremental approach to be helpful. Introduce routes one at a time and see what components need to be migrated over. Once a component is in Octane, write or port over rendering tests. After all components are done, write application tests for the route. Move to the next route and the next set of components. Rinse and repeat.
Often, you will find yourself recasting {{action}}
modifiers as a mix of @action
decorator, {{on}}
modifier, and {{fn}}
helper.
File: /app/templates/authors/details.hbs
<!-- Before -->
<button type="button" {{action "deleteAuthor" model}}>
Delete
</button>
<!-- After -->
<button type="button" {{on "click" (fn this.deleteAuthor @model)}}>
Delete
</button>
Though verbose, the new syntax helps you be clear with your intent. With practice, the syntax will become second nature. Visit the Ember Guides to learn more.
On a related note, take caution when converting actions called on a form submission. (I omitted Ember Concurrency in the following example to make the point clear.)
File: /app/templates/search.hbs
<!-- Before -->
<form {{action "searchStudents" on="submit"}}>
...
</form>
<!-- After -->
<form {{on "submit" this.searchStudents}}>
...
</form>
The {{action}}
modifier calls event.preventDefault();
and prevents page reload for you. In Octane, you express the intent to prevent the default behavior. You can find the event
object as the last argument to your function.
File: /app/controllers/search.js
// Before
actions: {
searchStudents() {
const skillIds = this.selectedSkills.mapBy('id').join(',');
...
}
}
// After
@action searchStudents(event) {
event.preventDefault();
const skillIds = this.selectedSkills.mapBy('id').join(',');
...
}
b. Test Suites
You can be confident with a rewrite if you have existing tests. Because my 3.8 tests already followed the new testing paradigm, my 3.15 tests needed a minor update: Replace server
with this.server
for Ember CLI Mirage. Note that, in component, helper, and modifier tests, hbs
is now a named import.
File: /tests/integration/components/loading/component-test.js
// Before
import hbs from 'htmlbars-inline-precompile';
// After
import { hbs } from 'ember-cli-htmlbars';
If you don't have existing tests, I encourage you to take time to write them for your future self. Learn more about testing in Ember.
c. Where Do Foos Come from?
As soon as you rewrite routes and components, you will love how you explicitly call things in a template. No more confusion over if {{foo}}
is a component, a helper, a passed argument, or a local property. (You now write <Foo>
, {{foo}}
, @foo
, and this.foo
, respectively. Ember will throw a helpful error for forgotten mistakes.)
File: /app/templates/students.hbs
<div>
{{#if this.showHeader}}
<h1>{{t "layout.students.header.students"}}</h1>
{{/if}}
<StudentsGrid
@students={{@model}}
/>
</div>
Although you are seeing this code for the first time, you can tell that <StudentsGrid>
is a component, {{t}}
is a (translation) helper, @model
is a passed argument, and this.showHeader
is a local property. You know which file to look next to learn more.
d. Template-Only Components
Glimmer components don't create a "wrapping-div," so you can say goodbye to tagName
, attributeBindings
, classNames
, and classNameBindings
. More often than not, these hindered me from reasoning the HTML code fast. After you remove these properties, smile when you see how many components don't need a backing class. 6 out of my 10 components became template-only.
One caveat with Glimmer components: elementId
, which was useful for binding a label to an input for accessibility, no longer exists. Instead, use guidFor(this)
in the backing class to create the ID.
File: /app/components/skill-pill/component.js
import { guidFor } from '@ember/object/internals';
import Component from '@glimmer/component';
export default class SkillPillComponent extends Component {
inputId = `input-${guidFor(this)}`;
}
e. Modifiers
When I dove into the rewrite, I wasn't sure about converting the modifier that I had used to demonstrate Web Animations API. I had used the ember-oo-modifiers addon, but I now wanted to use the official ember-modifier.
To my pleasant surprise, the code remained virtually the same. The new modifier even seemed to have fixed the animation bug that I had seen before. You got to give cheers to Ember contributors for following a well-defined API.
File: /app/modifiers/fade-up.js
import Modifier from 'ember-modifier';
export default class FadeUpModifier extends Modifier {
didReceiveArguments() {
const { duration, delay } = this.args.named;
this.element.animate(
[
{ opacity: 0, transform: 'translateY(60px)' },
{ opacity: 1, transform: 'translateY(0px)' }
],
{
duration: duration || 2000,
delay: delay || 0,
easing: 'cubic-bezier(0.075, 0.82, 0.165, 1)',
fill: 'backwards'
}
);
}
}
f. Avoid Shortcuts
In 3.8, I had created the search results-route as a child of the search. After all, a user would first search, then see the results. Nesting had seemed to convey that user flow accurately.
File: /app/router.js
Router.map(function() {
this.route('search', function() {
this.route('results');
});
});
Nesting normally implies that, when the user is on the results page, they will also see the search page. In reality, the app only shows the results page (which is what I had wanted) because I had used renderTemplate
to bypass the parent.
I don't recommend this practice since renderTemplate
is on the path to deprecation. The alternate solution is just as easy and doesn't load records that are never used in search results:
File: /app/router.js
Router.map(function() {
this.route('search');
this.route('search-results', { path: '/search/results' });
});
While you rewrite, I encourage you to return to the happy path that is paved with good practices. Your future updates will be easier. You can also review deprecations regularly and exchange ideas for solutions with people on Discord.
2. Lights Out (2.18 → 3.15)
Between the two, I found rewriting this app to be more interesting and rewarding. It is a simple app in terms of components: There is only 1 component. However, because I had written it while I was still new to Ember and D3, the app was riddled with hard-to-reason control flow. Oh, mixins, CPs, and observers...
By rewriting the app from scratch, I got to understand how to design a D3 component, perhaps with composability in mind.
- App: 3.15, 2.18
- Repo: 3.15, 2.18
- Blog post: Lights Out
a. Tracked Properties + Getters 💞
Tracked properties are magic. You no longer worry about whether a component should update along with when, where, why, and how. It just works.™ The code is cleaner, too, because you don't specify the dependency list.
The following snippet shows how to define a D3 scale. Should the object numButtons
or boardSize
change, the scale will be recomputed and anything that depends on the scale also.
File: /app/components/lights-out/component.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { scaleLinear } from 'd3-scale';
export default class LightsOutComponent extends Component {
@tracked numButtons = { x: 5, y: 5 };
get boardSize() { ... }
get scaleX() {
return scaleLinear()
.domain([0, this.numButtons.x])
.range([0, this.boardSize.x]);
}
get scaleY() {
return scaleLinear()
.domain([0, this.numButtons.y])
.range([0, this.boardSize.y]);
}
}
A small print: Updating a complex data structure may require extra work. (It always did, to be fair.) To update buttons
, a double array of objects, I made a deep copy and used set
:
File: /app/components/lights-out/component.js
import { set } from '@ember/object';
import { copy } from 'ember-copy';
export default class LightsOutComponent extends Component {
@tracked buttons;
toggleLights(i, j) {
let buttons = copy(this.buttons, true);
// Center
this.toggleLight(buttons[i][j]);
// Top, Bottom, Left, Right
...
this.buttons = buttons;
}
toggleLight(button) {
set(button, 'isLightOn', !button.isLightOn);
}
}
b. Modifiers to the Rescue 💯
During the rewrite, I was worried that I would mess up the control flow again. In 2.18, I had introduced a mixin and turned a blind eye to Ember's then-13 lifecycle hooks. I had also relied on computed properties and observers to force the flow my way.
Since Glimmer components have 2 lifecycle hooks by default, I had much less to work with. The D3 component also needed to react to a window resize. I wasn't sure where I would now create and destroy the event listeners.
These problems went away as soon as I discovered more modifiers. ember-render-modifiers provides the {{did-insert}}
modifier, and ember-did-resize-modifier the {{did-resize}}
modifier.
Thanks to these two, I was able to write a declarative, observer-free code:
File: /app/components/lights-out/template.hbs
<div class="lights-out"
{{did-insert this.setContainerSize}}
{{did-insert this.drawGame}}
{{did-insert this.startGame}}
{{did-resize this.setContainerSize debounce=25}}
{{did-resize this.drawGame debounce=25}}
>
...
</div>
I can look at this template and be confident that, when the div element is added to the DOM, Ember will set the game container size, draw the game, then start it. (The order of appearance is respected.) Similarly, when the div element changes size, Ember will set the game container size and draw the game again.
It's interesting that, by having less, I could do more.
3. Conclusion
Octane, the first edition of Ember, has really shifted for the better how developers will approach writing, architecting, and testing their apps. I saw a glimpse when I rewrote two apps over a weekend. I'm looking forward to learn more about Octane at work, Meetups, and conferences in 2020!
Since the beginning, a core value within Ember has been accelerating (boosting, etc.—add as many octane-related puns as possible) your journey to productivity. Thanks to seemingly small things like named arguments and improved Ember Inspector, as well as big, ambitious things like modifiers and tracked properties, writing apps in Ember is easy and fun.
Again, if you have written tutorials and demo apps before, I encourage you to rewrite them in Octane and share what you learned. If you have never tried out Ember before, you can start out with the official, revamped Super Rentals tutorial. Feel free to ask for help on Discord at any time!
Resources
If you want to learn more about Octane, I encourage you to visit these links:
- Octane Is Here
- Ember.js Octane vs Classic Cheat Sheet
- Bringing Clarity to Templates Through Ember Octane
- The Most Common Ember.js Octane Mistakes and How to Avoid Them
- Ember Atlas: Recommended Migration Order
- Ember Octane - Great for Beginners (video)
- Ember Octane Livestream: Build a Drum Machine (video)
Posted on December 24, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.