DEV Community

Cover image for Animation and Predictable Data Loading in Ember
Isaac Lee
Isaac Lee

Posted on • Updated on • Originally published at crunchingnumbers.live

Animation and Predictable Data Loading in Ember

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');
    },
});
Enter fullscreen mode Exit fullscreen mode
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',
        });
    },
});
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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.)

As of version 3.8, Ember documentation suggests that it's okay to pass model to the link-to helper. It is not.

Think carefully before passing a model to the link-to helper.

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',
        });
    },
});
Enter fullscreen mode Exit fullscreen mode

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,
        });
    },
});
Enter fullscreen mode Exit fullscreen mode

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',
        });
    },
});
Enter fullscreen mode Exit fullscreen mode

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 }),
});
Enter fullscreen mode Exit fullscreen mode

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');
    },
});
Enter fullscreen mode Exit fullscreen mode

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');
    },
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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',
        });
    },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Ember Data Storefront provides a runtime error for missing data relationships.

Ember Data Storefront can return a runtime error for missing relationships.

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>
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 })
            );
        });
    },
});
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode

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)
            );
        });
    },
});
Enter fullscreen mode Exit fullscreen mode

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}}
Enter fullscreen mode Exit fullscreen mode
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);
    },
});
Enter fullscreen mode Exit fullscreen mode
File: /app/styles/components/bar-chart.scss

.bar-chart {
    display: flex;

    .bar {
        border: 1px solid orange;
        width: 100px;
        height: 0;
        transition: height 2s ease;
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

You can find the code in its entirety here:

Download from GitHub

Top comments (0)