Updated 2019-09-30: updated for Ember 3.13.
Over the last year, Ember has been undergoing a lot of development as part of the upcoming Ember Octane edition to add features to make it easier to understand and allow it to take advantage of emerging JS ecosystem conventions. This includes:
- Angle bracket syntax for components
- Easy ES6 imports of NPM modules
- Decorators for clear and expressive component implementations
- Tracked properties to automatically rerender components and recompute derived data
- Co-locating component templates and classes for easy development
Most of these features have recently landed behind a feature flag in Ember 3.13. Let’s look at what it’s like to develop in Ember with these Octane Preview features enabled. You can also download the completed project if you like.
Project Setup
To create a new Ember project, make sure ember-cli
3.13 or higher is installed:
$ npm install -g ember-cli
$ ember --version
ember-cli: 3.13.1
Then, create a new Ember project:
$ ember new modern-ember
$ cd modern-ember
Next, enable the Octane Preview by running the following command:
$ npx @ember/octanify
Next we need to add a few additional packages. First, @glimmer/component
, Octane’s new component library:
$ npm install --save-dev @glimmer/component@^0.14.0-alpha.13
Second, ember-auto-import
, to allow working with any NPM package:
$ npm install --save-dev ember-auto-import
Other than the Glimmer Component package, all the Octane Preview features are released on the stable branch of Ember, behind a feature flag. This means you should be set to use the Octane Preview in production, with potentially some minor hiccups.
Components
With Ember you can jump right in to creating components without needing to look into other concepts yet. ember-cli
will allow us to easily generate a component’s files:
$ ember generate component MyComponent
This will create the following files:
app/components/my-component.hbs
- the markup for the componenttests/integation/components/my-component-test.js
- an automated test. Ember automatically adds tests alongside files it generates, but we won’t be looking into testing as part of this tutorial.
Note that the component file is a Handlebars file, which is just for output. If we need to add logic to the component, it will go in a separate JavaScript file, but this file isn’t generated by default. Having separate files has the advantage that each file is a normal .js or .hbs file.
Let’s add a message to the component markup. Replace the contents of my-component.hbs
with:
Hello!
Now we want to display the component. The main template file that renders the app is app/templates/application.hbs
. We can just add the component in there. Delete the contents of the file and replace it with:
<MyComponent />
Note that components don’t need to be imported anywhere; they’re automatically available in templates.
We can see this working by starting up the server with ember serve
. Go to http://localhost:4200
and you should see the “Hello!” message. You can leave the server running.
Arguments and Properties
Next, let’s pass in an argument to our component. Arguments are passed with an @
prefix:
<MyComponent @name="world" />
(Arguments are equivalent to props in React or Vue.)
In the component template itself, we access that argument using the @
sigil as well. This makes it clear at the point of use that the data comes in as an argument:
Hello, {{@name}}!
So far, this component is a stateless template-only component. The only data it has access to is what’s passed into it.
If we want the component to store its own data as well, we can add a JavaScript backing class for the component. This will allow us to store properties on the component itself and display them. (Properties are equivalent to state in React or data in Vue. They’re called “properties” because they’re just normal JavaScript object properties!)
Generate a JavaScript class for your component:
$ ember generate component-class MyComponent
This creates the file app/components/my-component.js
. Open it and add the following:
import Component from '@glimmer/component';
export default class MyComponent extends Component {
+ count = 0;
}
Ember uses plain object properties for data storage, rather than a framework-specific mechanism like a state
or data
object.
You can reference this property in the template with a this.
prefix, just as you would in JavaScript:
Hello, {{@name}}!
{{this.count}}
Actions
To update the data, we can implement an action on the component. We use a decorator to indicate that a method is available as an action.
import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
export default class MyComponent extends Component {
- count = 0;
+ @tracked count = 0;
+
+ @action
+ increment() {
+ this.count += 1;
+ }
}
By tagging a property with the @tracked
decorator, we indicate to Ember that the component should be rerendered when it is changed–for example, when we increment it by 1 here.
To call this action, add a button to the template:
Hello, {{@name}}!
{{this.count}}
+
+<button {{on "click" this.increment}}>Increment</button>
Computed Properties
Components can also have computed properties, implemented as normal ES5 getters. These are cached, and will be automatically recomputed when the @tracked
properties they depend upon change.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class MyComponent extends Component {
@tracked count = 0;
+ get uppercaseName() {
+ return this.args.name.toUpperCase();
+ }
+
@action
increment() {
Note that in the component the @name
argument is available as this.args.name
. Arguments aren’t placed directly on this
to ensure that they won’t collide with properties or actions on the component instance itself.
Then we update our template to pull from the computed property. Just like other properties, computed properties are accessed with the this.
prefix to indicate that it’s a property of the component instance.
-Hello, {{@name}}!
+Hello, {{this.uppercaseName}}!
Using NPM Packages
In Ember you can load data the same way you would in any other framework. To see this in action, let’s import the popular Axios web service client library:
$ npm install --save axios
Note that you don’t need to stop and rerun ember serve
to get the package loaded; it’ll pick it up automatically.
Next, in app/components/my-component.js
, initialize a records
property and populate it in the constructor()
- this is the normal ES6 class constructor that runs when the instance is initialized. We’ll use JSONPlaceholder for some convenient JSON data:
import { action } from '@ember/object';
+import axios from 'axios';
export default class MyComponent extends Component {
@tracked count = 0;
+ @tracked records = [];
+
+ constructor(owner, args) {
+ super(owner, args);
+
+ axios
+ .get('https://jsonplaceholder.typicode.com/posts')
+ .then(response => {
+ this.records = response.data;
+ });
+ }
get uppercaseName() {
Display it in the template:
<button {{on "click" this.increment}}>Increment</button>
+{{#each this.records as |record|}}
+ <p>
+ {{record.title}}
+ </p>
+{{/each}}
#each
is a helper that loops through an array, and provides a variable to the nested markup.
Moving Content to a Route Template
Now, say we want to display the contents of a post. Usually users would expect this to be on a separate route. Ember includes routing out of the box.
Earlier we added <MyComponent />
to our application/template.hbs
file. This file is the template that is always displayed no matter which route your app is on. To get MyComponent
to display only on the “home page”, generate an index route:
$ ember generate route index
This should create the following files
app/routes/index.js
- the route classapp/templates/index.hbs
- the route markup- a test file
Copy the component invocation from app/templates/application.hbs
and paste it into index.hbs
:
<MyComponent @name="world"/>
Now replace the contents of app/templates/application.hbs
with the following:
<h1>My App!</h1>
{{outlet}}
outlet
is a helper that will output the contents of the specific route you’re on. Reload your app and you should see the “My App” header as well as your MyComponent
content.
Creating Another Route
Let’s create a route for individual posts, using Ember CLI again. As you can tell, in Ember we use ember generate
a lot; you can use ember g
as a shortcut:
$ ember g route post
The generate route
command modified the file app/router.js
. Check the changes:
Router.map(function() {
this.route('post');
});
This configures a route at /post
. But we want to modify it to be at /post/27
for post ID 27, for example. To do this, we can customize the path of this route to include a dynamic segment, indicated with a :
prefix:
Router.map(function() {
this.route('post', { path: 'post/:id' });
});
You can’t access the id
variable from just anywhere; the place that has access to it is a route class, app/routes/post.js
. Open it. A route’s dynamic segments are passed to a method called model()
if one is defined. Add it:
export default class PostRoute extends Route {
+ model({ id }) {
+ }
}
Note that we can use destructuring to get the id
.
For now, let’s generate some fake post data. We’ll come back to share data between the routes later:
export default class PostRoute extends Route {
model({ id }) {
+ return {
+ title: `Post ${id}`,
+ body: `This is post ${id}!`,
+ };
}
}
In the route’s template app/templates/post.hbs
, let’s render a PostDetail
component:
<PostDetail @post={{this.model}} />
Earlier we used quotes around an argument when passing a hard-coded string, like you would for HTML attributes. When we want to pass any other JavaScript type or a dynamic value (such as the object stored in this.model
here) we need to use double-curlies.
Generate this PostDetail
component:
$ ember g component PostDetail
Then fill in its template:
<h3>{{@post.title}}</h3>
<p>{{@post.body}}</p>
This component is stateless so we won’t need a backing JavaScript class. Nice and simple!
Now, back in my-component.hbs
, we can link our post titles to that route:
{{#each this.records as |record|}}
<p>
- {{record.title}}
+ <LinkTo @route="post" @model={{record.id}}>
+ {{record.title}}
+ </LinkTo>
</p>
{{/each}}
LinkTo
is a built-in component that sets up a link to another route. We can pass a separate argument to it and it’s filled in as the dynamic segment.
The links should be clickable and take you to the route with the right ID, and our fake data will be shown.
Loading Data in a Service
To share data between components, we can put it in a service. The service can be injected into any component that needs it.
Generate a new service:
$ ember g service posts
This creates the following files:
app/services/posts.js
- the service class- a test file
Add a getAll()
method to the service that implements loading the data:
import Service from '@ember/service';
+import axios from 'axios';
export default class PostsService extends Service {
+ async getAll() {
+ const response = await axios.get(
+ 'https://jsonplaceholder.typicode.com/posts'
+ );
+ return response.data;
+ }
}
Note that we can use async/await
- it’s enabled by default in Ember’s Babel config.
Now update MyComponent
to inject the Posts
service to get the data from there instead of using Axios directly:
import { action } from '@ember/object';
-import axios from 'axios';
+import { inject as service } from '@ember/service';
export default class MyComponent extends Component {
+ @service posts;
+
@tracked count = 0;
@tracked records = [];
constructor(owner, args) {
super(owner, args);
- axios
- .get('https://jsonplaceholder.typicode.com/posts')
- .then(response => {
- this.records = response.data;
- });
+ this.posts.getAll().then(posts => {
+ this.records = posts;
+ });
}
get uppercaseName() {
Note that just by using the @service
decorator on a property, Ember knows to inject the Posts service when it creates the component. There’s no multi-step provider component process. This “dependency injection” approach also makes testing easier, because in a component test you can inject a fake service instead of the real one.
Service State
To avoid having to re-retrieve the post when we go to the detail page, let’s cache the post data in the service.
export default class PostsService extends Service {
+ posts = null;
+
async getAll() {
- const response = await axios.get(
- 'https://jsonplaceholder.typicode.com/posts'
- );
- return response.data
+ if (this.posts === null) {
+ const response = await axios.get(
+ 'https://jsonplaceholder.typicode.com/posts'
+ );
+ this.posts = response.data;
+ }
+
+ return this.posts;
}
}
Now that the posts are cached, we can easily add a method to retrieve a single post by ID:
return this.posts;
}
+
+ async findById(postId) {
+ const posts = await this.getAll();
+ return posts.find(p => p.id === postId);
+ }
}
Note that we call this.getAll()
instead of accessing this.posts
directly. This way, even if getAll()
hasn’t been called before, we’ll be sure to retrieve them.
Inject the service into the post route and call it in the model()
method to return the data:
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
export default class PostRoute extends Route {
+ @service posts;
model({ id }) {
- return {
- title: `Post ${post_id}`,
- body: `This is post ${post_id}!`,
- };
+ return this.posts.findById(Number(id));
}
}
Now, go to the root of your app, click a blog post link, and you should be taken to a detail page with the data.
Conclusion
We’ve looked at a lot of Ember’s new features: angle bracket syntax, template co-location, ES6 classes and decorators, tracked properties, auto importing of NPM modules. These changes remove some of the incidental differences between Ember and other frameworks, as well as some legacy cruft from before components were the mental model and before ES6 classes existed. Put together, they give Ember a very modern feel.
If you’re interested in a framework that gives you a reliable upgrade path as the web evolves, that takes care of implementation details so you can focus on your application’s business logic, and that values creating addons so you don’t have to reinvent the wheel, there are a lot of great reasons to check out Ember.