Originally posted on crunchingnumbers.live
At EmberConf 2019, I had the chance to meet and learn from many Ember developers around the globe. I'm excited about Ember Octane, a new edition built with developer productivity and app performance in mind. It's in beta and readying for release. I think there's no better time to learn and use Ember.
This tutorial covers how to load complex data in a predictable manner and how to add animation to liven up your site. A hearty thanks goes to Sam Selikoff and Ryan Toronto, whose teaching at the conference I'm heavily basing mine on. They had taken time to build a polished demo app; I was inspired to follow their footsteps.
tl;dr. Use Ember Animated and Ember Data Storefront today!
0. Finished App
You can see my demo app at https://ember-animated.herokuapp.com/. It is responsive and scales up to 4K screens.
Here is the scenario. You are looking to hire students (candidates) whose skills match your desired ones. From the Students tab, you can see all students at a glance and examine each in detail. From the Search tab, you can set the desired skills and find students who best meet them. You can navigate between tabs and pages in any order without encountering errors.
1. Predictable Data Loading
In the demo app, we have 5 models: Student, Resume, Degree, Experience, and Skill. These models are related through one-to-many and many-to-many relationships.
I think relationships are what makes Ember Data difficult to learn and use. Let me show you 4 types of bugs that you can encounter when you have models with relationships. I will then show how Ember Data Storefront helps us solve these problems.
a. {{link-to}} bug
After looking at all students, we click on one student to see their details. Strangely, we can see their name, email, phone, and profile image (the attributes), but not their degrees, experiences, and skills (the relationships). We click on another student to see a similar behavior. Lastly, when we refresh the page, we can see all information, but only of that student and no one else.
We suspect the problem to lie with route handlers, because they are responsible for loading data. When we examine the files, however, we see nothing wrong. Just good old friends, findAll
and findRecord
, from Ember.
File: /app/routes/students.js
import Route from '@ember/routing/route';
export default Route.extend({
model() {
return this.store.findAll('student');
},
});
File: /app/routes/students/student.js
import Route from '@ember/routing/route';
export default Route.extend({
model(params) {
return this.store.findRecord('student', params.id, {
include: 'resumes,resumes.degrees,resumes.experiences,resumes.skills',
});
},
});
The culprit turns out to be a template that creates a link to each student. We encounter this template when we visit students
and students.student
routes. Can you spot the problem?
File: /app/components/students-grid/template.hbs
{{#let (component "students-grid/card") as |Card|}}
{{#each (sort-by "lastName" "firstName" students) as |student|}}
<li>
{{#link-to "students.student" student}}
<Card @student={{student}} />
{{/link-to}}
</li>
{{/each}}
{{/let}}
In line 4, we passed the student
model to the {{link-to}}
helper. When we do so, Ember skips calling the model
hook of the students.student
route. I already have the model, so why should I fetch it again? Although Ember thinks it is making a smart decision, the user is actually missing out on crucial data.
The fix is simple. To ensure that Ember calls the model
hook, we pass the model ID.
File: /app/components/students-grid/template.hbs
{{#let (component "students-grid/card") as |Card|}}
{{#each (sort-by "lastName" "firstName" students) as |student|}}
<li>
{{#link-to "students.student" student.id}}
<Card @student={{student}} />
{{/link-to}}
</li>
{{/each}}
{{/let}}
I think the {{link-to}}
syntax that results from passing the ID makes more sense. After all, if I were to create a URL for a student, i.e. /students/:some_parameter
, I would want to indicate what identifies them.
Unfortunately, I believe the mistake of passing the model is easy to make for new and seasoned developers. I didn't know the difference until I attended Sam and Ryan's talk. Even Ember documentation suggests that passing the model is okay. (It later corrects code and warns us in a different section, buried under other texts.)
Sam and Ryan commented that they created their own link-to helper that can handle both model and ID gracefully. They also suggested that we lint against {{link-to}}
so that passing a model results in a runtime error.
tl;dr. Always pass the model ID to {{link-to}}
.
b. findRecord bug
After fixing the {{link-to}}
bug, we can now see a student's degrees, experiences, and skills. However, these information pop on the screen after a delay. Refreshing the page lends to the same behavior. Can we prevent the template from "flashing"? Why does this happen in the first place?
The key is that the students
route nests the students.student
route (you can check this in /app/router.js
). As a result, when we visit the student details page, whether through Students tab or directly by URL, Ember calls the model
hook of students
, the parent route handler, first. In other words, when Ember tries to load the details page, it already has the student's name, email, phone, and profile image. Why not show them immediately?
Let's take another look at students.student
, the child route handler.
File: /app/routes/students/student.js
import Route from '@ember/routing/route';
export default Route.extend({
model(params) {
return this.store.findRecord('student', params.id, {
include: 'resumes,resumes.degrees,resumes.experiences,resumes.skills',
});
},
});
The include
query parameter, specific to the JSON:API, allows us to sideload related data. Since resumes, degrees, experiences, and skills take an extra call, they get shown in the template at a later time.
One way to mitigate flashing is to pass the reload
parameter of true
. Ember will block rendering until it has reloaded the student data.
File: /app/routes/students/student.js
import Route from '@ember/routing/route';
export default Route.extend({
model(params) {
return this.store.findRecord('student', params.id, {
include: 'resumes,resumes.degrees,resumes.experiences,resumes.skills',
reload: true,
});
},
});
However, by setting reload
to true
, we lose the benefit of caching that findRecord
provides. Every time we visit the student's page, we are loading that student's data. (We know this because we see the loading spinner.) Perhaps we can set reload
to true
on an initial visit, then to false
for subsequent visits, but this leads to more work and complex code.
Sam and Ryan (I too) advocate for writing a declarative model
hook. Simply put, we should be able to express our ideas in code without worrying about implementation details. In addition, if the complex logic of loading data can all be done in the model
hook, we wouldn't see side effects that may arise from spreading the logic to other hooks (e.g. afterModel
).
Their solution, Ember Data Storefront, does just this. It's easy to use too!
First, we change Ember Data's findRecord
to Ember Data Storefront's loadRecord
.
File: /app/routes/students/student.js
import Route from '@ember/routing/route';
export default Route.extend({
model(params) {
return this.store.loadRecord('student', params.id, {
include: 'resumes,resumes.degrees,resumes.experiences,resumes.skills',
});
},
});
Second, we update the student model to extend the LoadableModel
mixin, so that we can use loadRecord
in the route handler. We also set all async
options to false
so that we can avoid unexpected surprises. (See Notes to learn why.)
File: /app/models/student.js
import DS from 'ember-data';
import LoadableModel from 'ember-data-storefront/mixins/loadable-model';
export default DS.Model.extend(LoadableModel, {
resumes: DS.hasMany('resume', { async: false }),
});
That's it. No step 3.
Ember Data Storefront blocks rendering until all data are present. It is also query-aware. It will return the cached data if we made the query before or if the included models can be decomposed into past queried ones.
tl;dr. Use loadRecord
instead of findRecord
.
c. findAll bug
We can now view a student's details. Next, we want to specify our desired skills and find students who best meet them.
If we navigate directly from Home to Search, we will see all skills without a problem. However, if we visit a student details page, then visit Search, we see only the skills of that student. When we navigate to Home then back to Search, we see all skills once again. What's going on?
This time, let's have a look at the search
route handler.
File: /app/routes/search.js
import Route from '@ember/routing/route';
export default Route.extend({
model() {
return this.store.findAll('skill');
},
});
Ember Data's findAll
, like its singular counterpart findRecord
, uses caching and background reload so that the user sees some content immediately while fresh data gets served. Unfortunately, this creates side effects when we have related models and can't predict all possible states that arise from user interaction.
Again, one solution is to pass { reload: true }
and forget about caching. The other is to use Ember Data Storefront.
File: /app/routes/search.js
import Route from '@ember/routing/route';
export default Route.extend({
model() {
return this.store.loadRecords('skill');
},
});
tl;dr. Use loadRecords
instead of findAll
.
d. (n + 1) query bug
The last bug concerns making excessive AJAX requests due to relationships. Consider this simplified student-details
template.
File: /app/components/student-details/template.hbs
<ul>
{{#each resume.degrees as |degree|}}
<li>{{degree.name}}</li>
{{/each}}
</ul>
We already made 1 request to get the student. Without proper treatment, the template will make n additional requests, one for each degree. Hence, (n + 1).
Thanks to JSON:API, we can make 1 request with all data necessary to render the page. In Ember, we pass the include
query parameter.
File: /app/routes/students/student.js
import Route from '@ember/routing/route';
export default Route.extend({
model(params) {
return this.store.loadRecord('student', params.id, {
include: 'resumes,resumes.degrees,resumes.experiences,resumes.skills',
});
},
});
Then, in Rails (or your preferred backend), we allow eager loading.
File: /api/app/controllers/students_controller.rb
class StudentsController < ApplicationController
def show
render json: StudentSerializer.new(
@student,
include: [:resumes, :'resumes.degrees', :'resumes.experiences', :'resumes.skills']
)
.serialized_json
end
end
As a tidbit, Ember Data Storefront provides the AssertMustPreload
component. It throws a runtime error for missing relationships.
File: /app/components/student-details/template.hbs
{{assert-must-preload
student
"resumes,resumes.degrees,resumes.experiences,resumes.skills"
}}
<ul>
{{#each resume.degrees as |degree|}}
<li>{{degree.name}}</li>
{{/each}}
</ul>
We can also use AssertMustPreload
to prevent (n + 1) queries. If we had created all relationships with { async: false }
, a runtime error would imply that we forgot to use include
. Without include
, we would make (n + 1) queries if we loop over the related data. QED.
tl;dr. Use include
.
2. Animation
Now we can load data in a predictable manner. But so far, our app is static, in the sense that it shows content without flair. Here, I want to convey to you that even small drops of animation can make our app feel dynamic and polished.
In addition to CSS transition (please see Notes for more information), we can use Web Animations API and Ember Animated to create animations in Ember.
a. Web Animations API
A details page, whose content is heavy, can be overwhelming to look at. Let's spruce it up by fading in sections one at a time. We can use a modifier, introduced in Ember 3.8 and Octane, to solve this problem easily. A modifier modifies a DOM element and lets us stretch our imaginations.
I have a modifier called fade-up
. First, let me show you how we use the modifier.
File: /app/components/student-details/template.hbs
<header {{fade-up id=id}}>
<h1>{{fullName}}</h1>
</header>
<section {{fade-up id=id}}>
<ProfileImage @imageUrl={{imageUrl}} @altText={{fullName}} />
</section>
<section {{fade-up id=id delay=50}}>
<header>
<h2>Email</h2>
</header>
<a href="mailto:{{email}}">
{{email}}
</a>
</section>
With these simple changes, we can fade in the name and profile image immediately, while fade in the email shortly after (50 ms). We can similarly modify the remaining sections with longer delays to create a staggered effect.
Next, let's check how our modifier works.
File: /app/modifiers/fade-up.js
import { Modifier } from 'ember-oo-modifiers';
const FadeUpModifier = Modifier.extend({
didReceiveArguments(args, options) {
this.element.animate(
[
{ opacity: 0, transform: 'translateY(60px)' },
{ opacity: 1, transform: 'translateY(0px)' },
],
{
duration: options.duration || 2000,
delay: options.delay || 0,
easing: 'cubic-bezier(0.075, 0.82, 0.165, 1)',
fill: 'backwards',
},
);
},
});
export default Modifier.modifier(FadeUpModifier);
The modifier extends the one from Ember OO Modifiers addon. Inside the modifier, we have a reference to the DOM element; it's just this.element
. We call .animate
from Web Animations API and pass two parameters—an array of keyframes and an options hash—to describe how we want to animate the element.
We use the didReceiveArguments
hook so that the modifier will be called again when we switch between child routes (the model ID changes). The options
parameter is an object that can take any form. Pass whatever you'd like. Here, I'm allowing ourselves to control the animation duration
and delay
.
Web Animations API isn't a perfect solution, however. The API is experimental and our browsers don't fully support it. You may end up needing a polyfill.
At the time of writing, the fade-up
modifier also has a problem. (I'm not sure whether I misused Ember's modifier or Ember OO Modifiers had a bug.) If you visit a child route, say /students/1
, then another child, /students/2
, and come back to /students/1
(i.e. cached data), you may end up seeing the animation happen twice.
b. Ember Animated
Finally, let's learn how to create more complex animations.
Ember Animated comes with 2 helpers, {{animated-each}}
and {{animated-if}}
, among other things. The idea is, we simply replace Ember's {{each}}
and {{if}}
in our code with their animated-
counterparts.
i. animated-each
Consider this simplified search
template. It shows the selected skills, sorted by their category and name.
File: /app/templates/search.hbs
<span>Selected:</span>
{{#each
(sort-by "category" "name" selectedSkills)
as |skill|
}}
<SkillPill @skill={{skill}} />
{{/each}}
Then, we introduce {{animated-each}}
as follows:
File: /app/templates/search.hbs
<span>Selected:</span>
<AnimatedContainer>
{{#animated-each
(sort-by "category" "name" selectedSkills)
use=transition
as |skill|
}}
<SkillPill @skill={{skill}} />
{{/animated-each}}
</AnimatedContainer>
I think it's brilliant how {{animated-each}}
just works with other addons. The sort-by
helper comes from Ember Composable Helpers. I didn't have to do extra work to make the two play nice.
The {{animated-container}}
reserves space for animation. Should there be content after the loop, it will gracefully step aside as the skills enter and exit the container.
Let's take a look at transition
that we used in the template. We can find its definition in the search
controller.
File: /app/controllers/search.js
import Controller from '@ember/controller';
export default Controller.extend({
*transition({ insertedSprites, keptSprites, removedSprites }) {
insertedSprites.forEach(fadeIn);
keptSprites.forEach(sprite => {
parallel(
fadeIn(sprite),
move(sprite, { easing: easeIn })
);
});
removedSprites.forEach(sprite => {
sprite.endTranslatedBy(60, 80);
parallel(
fadeOut(sprite),
move(sprite, { easing: easeOut })
);
});
},
});
In line 4, we see that transition
is a generator function. Anything a regular function can do, generator functions can also. But generator functions can do even more. They allow us to yield (output) intermediate values and check the context (the current state) when we enter the function. You can imagine how these features would be useful for animation.
In Ember Animated, the context is an object that keeps track of 5 types of sprites. In the example above, we make use of three of them: insertedSprites
(elements that are to be added to the DOM), keptSprites
(those that are to stay), and removedSprites
(those that will be removed). For each type (and quite possibly, for each sprite), we can define their movement.
ii. animated-if
Next, let's animate the navigation.
Here's the simplified navigation-drawer
template. We see that if a nav item's route matches the current one, then we add a highlight below the text.
File: /app/components/navigation-drawer.hbs
{{#each navItems as |navItem|}}
{{#link-to navItem.route}}
<span>{{navItem.label}}</span>
{{#if (eq navItem.route currentParentRoute)}}
<div class="highlighted" aria-hidden="true"></div>
{{/if}}
{{/link-to}}
{{/each}}
First, we replace {{if}}
with {{animated-if}}
. We pass the group
property to treat the 3 <div>
elements as if they were one.
File: /app/components/navigation-drawer.hbs
{{#each navItems as |navItem|}}
{{#link-to navItem.route}}
<span>{{navItem.label}}</span>
{{#animated-if
(eq navItem.route currentParentRoute)
use=transition
group="navItems"
}}
<div class="highlighted" aria-hidden="true"></div>
{{/animated-if}}
{{/link-to}}
{{/each}}
Second, we define the transition
.
File: /app/components/navigation-drawer/component.js
import Component from '@ember/component';
export default Component.extend({
*transition({ receivedSprites }) {
receivedSprites.forEach(sprite => {
parallel(
move(sprite),
scale(sprite)
);
});
},
});
This time, we use receivedSprites
to express how we want to animate the highlight. The highlight simply moves to its final position and changes its size (its width, to match the text width).
receivedSprites
and sentSprites
are the other two types of sprites. They can be used to animate sprites that move between two different components.
3. Conclusion
Thanks to addons like Ember Animated and Ember Data Storefront, we can really build ambitious apps and let our imaginations come alive. I had a lot of fun (and lost a lot of sleep) creating my demo app, and hope that you will enjoy creating too.
As great as these addons are, they are also work in progress; they need your help in achieving stability and realizing their full potential. I'll close by reiterating Kenneth Larsen's message at EmberConf. If you have time, please give back to your community by helping with documentations, issues, or tutorials. Be awesome to each other.
Notes
From Ember Discord, I realized that adding a data-driven CSS transition is neither obvious nor well-documented. Suppose we have a component called bar-chart
. It draws bars and sets their heights dynamically.
File: /app/components/bar-chart/template.hbs
{{#each bars as |bar|}}
<div
class="bar"
style={{if initialRender (concat "height: " bar.height "%;")}}
>
</div>
{{/each}}
File: /app/components/bar-chart/component.js
import Component from '@ember/component';
import { later } from '@ember/runloop';
export default Component.extend({
classNames: ['bar-chart'],
didRender() {
this._super(...arguments);
later(() => {
this.set('initialRender', true);
}, 1);
},
});
File: /app/styles/components/bar-chart.scss
.bar-chart {
display: flex;
.bar {
border: 1px solid orange;
width: 100px;
height: 0;
transition: height 2s ease;
}
}
The key is that we can use Ember's later
to control when the CSS transition should be applied (after the initial render).
For more information on animation and predictable data loading, I encourage you to visit the following links:
Ember Data Storefront at Ember.js NYC, by Sam Selikoff and Ryan Toronto
EmberConf 2018 Living Animation, by Edward Faulkner
EmberConf 2019 Real-World Animations, by Sam Selikoff and Ryan Toronto
EmberConf 2019 Robust Data Fetching, by Sam Selikoff and Ryan Toronto
N + 1 Queries or Memory Problems: Why Not Solve Both?, by Richard Schneeman
The Case Against Async Relationships, by Ryan Toronto
What Are Modifiers?, by Kristen Garrett
You can find the code in its entirety here:
Top comments (0)