Last reviewed in June 2019 with Ember Octane
If you just got started with Ember, it can be hard to wrap your head around the framework's architecture.
Templates, routes, controllers, models, components… Are all of them useful? Which logic belongs where? What's the relationship between them?
The five core concepts are Ember's fundamental building blocks: routing, models, services, controllers/templates, and components:
In its simplest form, an Ember app uses the routing layer to resolve, based on the URL, a particular model which is then handed over to a controller/template (which in turn can call components) for display and interaction. A service may be used to retrieve models, for example.
Now that we have a modest idea about how concepts relate to one another, let's move on with an example:
What's going on here?
/posts/2
URL and dispatches control to a matching route: the post
route, with a parameter post_id=2
post
route invokes its model()
hook, which returns a model (Post
with id=2
) fetched with a service. (The service is called the Ember Data Store. More about this later!)post
controller/template (that correspond to the post
route). It then sets the result of the model()
hook on a property of the controller/template. This property is the model
property.You might have noticed the word model
was mentioned several times above, with slightly different spellings.
model()
: the hook. Hooks are functions in routes that are automatically called by the framework in a typical request-response cycle. Generally, any time a URL is requested by the browser model()
will be called.model
: the property on the controller/template. model()
can be asynchronous. Once its value is returned or its promise resolved, the result will be set in the model
property of the controller/template.Great! Now that we understand the flow of a basic application, it's time to dive deeper.
The URL is a first-class citizen in Ember. See the diagram above – everything starts at the URL.
It represents the state of the current page's primary data. For example, a page displaying comments for blog post with ID=5 could naturally have a URL of http://ember.app/posts/5/comments
.
Comments are primary data. A tag cloud on that same page loaded by another service would be considered secondary data and shouldn't belong to the URL.
In order to map URLs to routes we have an object called the Router:
// app/router.js
// ...
Router.map(function() {
this.route('posts', function() {
this.route('post', { path: ':post_id' });
});
});
// ...
Ember has a mechanism to locate different kind of files (routes, controllers, services, etc). A standard path for PostsPostRoute
is app/routes/posts/post.js
.
When the URL is requested, the PostsPostRoute
will invoke model()
from where we return our model:
// app/routes/posts/post.js
import Route from '@ember/routing/route';
export default class PostsPostRoute extends Route {
model({ post_id }) {
return {
id: post_id,
body: "Lorem Ipsum"
}
}
});
As an example we're returning a dummy object. As we'll see next, there are other more interesting things we can return from model()
.
Snacks is the best of Ember Octane in a highly digestible monthly newsletter. (No spam. EVER.)
A model is a representation of a domain entity. A blog post could be modelled as Post
with id
, tag
, date
, text
as attributes and comments
or author
as relationships.
Models can be represented in a JSON format:
{
id: 2,
date: '2018-12-12 05:28',
body: "Lorem ipsum",
comment_ids: [24, 52, 89],
author_id: 9
}
One such remote resource can be fetched with the popular axios
library:
import Route from '@ember/routing/route';
import axios from 'axios';
export default class PostsPostRoute extends Route {
model({ post_id }) {
return axios.get(`https://app.api/posts/${post_id}`);
}
});
This is not returning a JSON object though! It is an async call over the network. It returns a Promise
.
Promise
s are objects that represent a future value. They can successfully resolve this value (fulfill) or fail to resolve it (reject).
This is why we were using the word “resolve”: the route will wait until that promise fulfills or rejects before proceeding with the render to template phase.
So the model()
hook works with both sync and async!
By far the most common async APIs in this scenario are Ember Data's findAll
and findRecord
:
export default class PostsPostRoute extends Route {
model({ post_id }) {
return this.store.findRecord('post', post_id);
}
});
The call above will resolve to an instance of Post
, which inherits from ember-data/model
like all Ember Data models do.
At this point, the post will be set to the post
controller's model
property and is ready to be rendered.
Did you notice we just called this.store
? The way we interact with Ember Data (query, save, etc) is through its store interface.
But where does that store
property come from?
That ain't any Ember Data magic. Under the hood, Ember Data uses an initializer to automatically inject its store
service into all routes of the application.
An Ember Service is a long-lived object (singleton) used to provide services to other Ember objects. It directly extends Ember Object
but uses its own class name to encourage the services architecture pattern.
We can create our own custom service and have it injected exactly like the Ember Data store – on any Ember object we want!
We might have a tag cloud service that provides us with weighted tags for all the content in our site. For ad-hoc injection we'd use the following syntax:
import Component from '@ember/component';
import { inject as service } from '@ember/service';
export default class SomeComponent extends Component {
@service tagCloud;
}
Which will look up a service at app/services/tag-cloud.js
and make it available as a tagCloud
property on a component, for example.
Services are the preferred way to hold session-wide state in an Ember app.
// app/services/tag-cloud.js
import Service from '@ember/service';
export default class TagCloud extends Service {
currentTags: [];
generateCloud() {
// ...
};
}
So far all we know about controllers is that they are Ember objects initialized by the route, and that resolved models are set on their model
property.
Their purpose is to define UI logic around the model, and other behavior such as query parameters.
There is a 1:1 relationship between routes and controllers – even if we haven't explicitly defined them. (Remember convention over configuration.)
Controllers, like services, are singletons. So the model
state will be kept around even when a user is browsing other URLs.
Now back to our previous example, our model
(the post) is ready to be rendered. For this, Ember uses Handlebars templates.
Template and controller are essentially the same thing – two sides of the same coin. All the controller properties are the template's. That's why the diagram above shows them together inside the same blue box.
// app/controllers/posts/post.js
import Controller from '@ember/controller';
import { alias } from '@ember/object/computed';
export default class PostsPostController extends Controller {
@alias('model.body') content;
}
{{!-- app/templates/posts/post.hbs --}}
<h1>ID is {{ model.id }}</h1>
{{#if content }}
<p>{{ content }}</p>
{{/if}}
In a very simple Ember app, you might not see a route or a controller. Why? Because they have been auto-generated for you.
When a route loads a model
and sets it on its controller, the template has immediate access to it. If there are no computed properties (like content
in the example above) or other behavior required, creating a controller file is not necessary.
Components take the code/markup combo idea and make it reusable. They consist of a Javascript source file (like a controller) and its corresponding Handlebars template. Again, two sides of the same coin!
Unlike controllers, they are isolated by design. They are not aware of their surrounding context, but they receive data through their attributes, including actions (closure functions).
We can turn the above controller/template combo into a component:
// app/components/show-post.js
import Component from '@ember/component';
import { alias } from '@ember/object/computed';
export default class ShowPost extends Component {
@alias('model.body') content;
}
{{!-- app/templates/components/show-post.hbs --}}
<h1>ID is {{ model.id }}</h1>
{{#if content }}
<p>{{ content }}</p>
{{/if}}
This component can be invoked from any template using the <ShowPost>
syntax:
{{#each posts as |post|}}
<ShowPost @model=post />
<EditAuthor @model=post.author @onAfterSave=(action 'sendAnalytics') class="green-box" />
{{/each}}
The EditAuthor
component in this example receives data attributes, an action, and a plain HTML attribute.
In a nutshell, we say that a model
has an owner. Certainly a contentious definition,
but generally speaking the controller or component that lists the model's attributes for mutation is considered the owner of that model.
The model owner is the object used to persist the model to the backend (via Ember Data Store or other mechanism).
In our case, one could argue that the controller owns the blog post model and, in our last example, the EditAuthor
component owns the post's author model.
Here's a table comparing controllers and components:
Controller | Component | |
---|---|---|
Javascript side of a template | Yes | Yes |
Can be called from any template | No | Yes |
State span | Singleton (held during session) | Ephemeral (lost on rerender) |
Query params | Can hold QP state | No |
Useful hooks | No, has to use observers | Yes, didInsertElement , didUpdateAttrs among others |
Snacks is the best of Ember Octane in a highly digestible monthly newsletter. (No spam. EVER.)
Let's expand on this idea of passing actions as attributes.
We're going to modify our previous blog post example:
// app/controllers/posts/post.js
import Controller from '@ember/controller';
import { action } from '@ember/object';
import { alias } from '@ember/object/computed';
export default class PostsPostController extends Controller {
@alias('model.body') content;
@action
async removePost(model) {
await model.destroyRecord();
this.transitionToRoute('/');
}
}
{{!-- app/templates/posts/post.hbs --}}
<h1>ID is {{ model.id }}</h1>
{{#if content }}
<p>{{ content }}</p>
{{/if}}
<button {{action this.removePost model}}>
Remove post
</button>
Updating our diagram to reflect the removePost
action:
Do you notice that data flows down and actions up? This is an architectural pattern we call Data Down, Actions Up (“DDAU”).
Understanding these 5 concepts is a fantastic start! Good Ember apps are architected this way.
Getting Started with Ember Octane is a great way to put in practice what we just discussed.
What Ember concept are you struggling with? Let me know in the comments below!
Snacks is the best of Ember Octane in a highly digestible monthly newsletter. (No spam. EVER.)