Ember Octane represents a major evolution of the Ember.js framework, introducing modern JavaScript patterns, improved reactivity, and simpler component APIs. This guide covers the key features and how to leverage them in your applications.
What is Ember Octane?
Ember Octane is the third edition of Ember.js, released in 2019, bringing:
- Native JavaScript classes instead of Ember Object
- Tracked properties for reactivity (no more computed properties)
- Glimmer components (lighter, faster)
- Decorators for cleaner syntax
- Native JavaScript patterns throughout
Tracked Properties
Tracked properties replace computed properties:
import { tracked } from '@glimmer/tracking';
export default class UserProfile {
@tracked firstName = 'John';
@tracked lastName = 'Doe';
// Use getters for computed values
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
updateName(first, last) {
this.firstName = first;
this.lastName = last;
// fullName updates automatically
}
}How Tracked Works
Tracked properties work through autotracking:
import { tracked } from '@glimmer/tracking';
class ShoppingCart {
@tracked items = [];
get total() {
// This getter will recompute when items changes
return this.items.reduce((sum, item) => sum + item.price, 0);
}
get tax() {
// This uses total, so it also recomputes
return this.total * 0.1;
}
addItem(item) {
// Must replace the array reference
this.items = [...this.items, item];
}
}Tracked Objects
For complex state, use tracked objects:
import { TrackedObject } from '@glimmer/tracking';
const user = new TrackedObject({
name: 'John',
email: 'john@example.com'
});
// Update tracked
user.name = 'Jane'; // Triggers updatesTracked Maps and Sets
import { TrackedMap, TrackedSet } from '@glimmer/tracking';
class TagManager {
tags = new TrackedSet();
userTags = new TrackedMap();
addTag(tag) {
this.tags.add(tag);
}
setTagForUser(userId, tag) {
this.userTags.set(userId, tag);
}
}Native Classes
Ember Octane uses native JavaScript classes:
Route Classes
import Route from '@ember/routing/route';
import { inject as service } from '@ember/service';
export default class UsersRoute extends Route {
@service store;
model() {
return this.store.findAll('user');
}
setupController(controller, model) {
super.setupController(controller, model);
controller.sortProperty = 'name';
}
}Controller Classes
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class UsersController extends Controller {
@tracked searchTerm = '';
@tracked sortBy = 'name';
get filteredUsers() {
let users = this.model;
if (this.searchTerm) {
const term = this.searchTerm.toLowerCase();
users = users.filter(user =>
user.name.toLowerCase().includes(term)
);
}
return users.sortBy(this.sortBy);
}
@action
updateSearchTerm(event) {
this.searchTerm = event.target.value;
}
@action
setSortProperty(property) {
this.sortBy = property;
}
}Component Classes
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class TodoListComponent extends Component {
@tracked filter = 'all';
get filteredTodos() {
const todos = this.args.todos;
switch (this.filter) {
case 'active':
return todos.filter(t => !t.completed);
case 'completed':
return todos.filter(t => t.completed);
default:
return todos;
}
}
@action
setFilter(filter) {
this.filter = filter;
}
}Decorators
Decorators provide a cleaner syntax for Ember features:
Service Injection
import { inject as service } from '@ember/service';
export default class MyComponent extends Component {
@service store;
@service router;
@service session;
// Use the services
async saveUser() {
await this.store.createRecord('user', {
name: 'John'
}).save();
this.router.transitionTo('users');
}
}Tracked Properties
import { tracked } from '@glimmer/tracking';
export default class CounterComponent extends Component {
@tracked count = 0;
@action
increment() {
this.count++;
}
}Actions
import { action } from '@ember/object';
export default class FormComponent extends Component {
@tracked email = '';
@action
async submit(event) {
event.preventDefault();
await this.args.onSubmit({ email: this.email });
}
@action
reset() {
this.email = '';
}
}Computed Properties (Legacy)
While Octane prefers getters, computed properties still work:
import { computed } from '@ember/object';
export default class UserComponent extends Component {
@tracked firstName = 'John';
@tracked lastName = 'Doe';
@computed('firstName', 'lastName')
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
}Glimmer Components
Glimmer components are the new standard:
Template-Only Components
{{! app/components/avatar.hbs }}
<div class="avatar">
<img src={{@src}} alt={{@alt}} />
</div>No JavaScript file needed!
JavaScript Components
// app/components/user-card.js
import Component from '@glimmer/component';
export default class UserCardComponent extends Component {
get isAdmin() {
return this.args.role === 'admin';
}
get initials() {
const name = this.args.name || '';
return name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
}Modifiers Instead of Lifecycle Hooks
// Old way (classic component)
import Component from '@ember/component';
import { run } from '@ember/runloop';
export default Component.extend({
didInsertElement() {
this._super(...arguments);
run.scheduleOnce('afterRender', this, this.setupChart);
},
willDestroyElement() {
this._super(...arguments);
this.teardownChart();
}
});// New way (glimmer component)
import Component from '@glimmer/component';
import { action } from '@ember/object';
export default class ChartComponent extends Component {
chart = null;
@action
setupChart(element) {
this.chart = new Chart(element, {
type: 'bar',
data: this.args.data
});
}
@action
teardownChart() {
if (this.chart) {
this.chart.destroy();
}
}
}{{! app/components/chart.hbs }}
<div {{did-insert this.setupChart}} {{will-destroy this.teardownChart}}>
{{yield}}
</div>Async Actions
Modern async/await in actions:
import { action } from '@ember/object';
export default class UserFormComponent extends Component {
@tracked isSaving = false;
@tracked error = null;
@action
async handleSubmit(event) {
event.preventDefault();
this.isSaving = true;
this.error = null;
try {
const user = await this.args.onSubmit({
name: this.name,
email: this.email
});
this.name = '';
this.email = '';
this.args.onSuccess?.(user);
} catch (err) {
this.error = err.message;
} finally {
this.isSaving = false;
}
}
}<form {{on "submit" this.handleSubmit}}>
<input
value={{this.name}}
{{on "input" (pick "target.value" (fn this.updateField "name"))}}
/>
{{#if this.isSaving}}
<p>Saving...</p>
{{/if}}
{{#if this.error}}
<p class="error">{{this.error}}</p>
{{/if}}
</form>Dependency Injection
Using services with decorators:
import { inject as service } from '@ember/service';
export default class ShoppingCartComponent extends Component {
@service cart;
@service router;
@service notifications;
@action
async checkout() {
try {
const order = await this.cart.checkout();
this.notifications.success('Order placed!');
this.router.transitionTo('order', order.id);
} catch (error) {
this.notifications.error('Checkout failed');
}
}
}Testing Octane Code
Testing tracked properties and actions:
// tests/integration/components/counter-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
module('Integration | Component | counter', function(hooks) {
setupRenderingTest(hooks);
test('it increments count', async function(assert) {
await render(hbs`<Counter />`);
assert.dom('[data-test-count]').hasText('0');
await click('[data-test-increment]');
assert.dom('[data-test-count]').hasText('1');
});
test('it decrements count', async function(assert) {
await render(hbs`<Counter @initialValue={{5}} />`);
assert.dom('[data-test-count]').hasText('5');
await click('[data-test-decrement]');
assert.dom('[data-test-count]').hasText('4');
});
});Migration from Classic to Octane
Before (Classic)
import Component from '@ember/component';
import { computed } from '@ember/object';
export default Component.extend({
classNames: ['user-card'],
isPremium: computed('user.tier', function() {
return this.get('user.tier') === 'premium';
}),
fullName: computed('user.{firstName,lastName}', function() {
return `${this.get('user.firstName')} ${this.get('user.lastName')}`;
}),
actions: {
saveUser() {
this.get('onSave')(this.get('user'));
}
}
});After (Octane)
import Component from '@glimmer/component';
import { action } from '@ember/object';
export default class UserCardComponent extends Component {
get isPremium() {
return this.args.user.tier === 'premium';
}
get fullName() {
const { firstName, lastName } = this.args.user;
return `${firstName} ${lastName}`;
}
@action
saveUser() {
this.args.onSave(this.args.user);
}
}Best Practices
- 1Use Tracked Properties: Always use
@trackedfor reactive state - 2Prefer Getters: Use getters instead of computed properties
- 3Template-Only Components: Use them when possible
- 4Decorators: Leverage decorators for cleaner code
- 5Async/Await: Use modern async patterns
- 6Native Classes: Use native JavaScript classes everywhere
Performance Tips
Avoid Tracked When Not Needed
// Bad - tracked but never changes
@tracked staticConfig = { apiUrl: 'https://api.example.com' };
// Good - regular property
staticConfig = { apiUrl: 'https://api.example.com' };Memoize Expensive Computations
import { cached } from '@glimmer/tracking';
export default class DataProcessor extends Component {
@tracked rawData;
@cached
get processedData() {
// Expensive operation runs only when rawData changes
return this.rawData.map(transform);
}
}Use Modifiers Efficiently
{{#each this.items as |item|}}
<div {{did-insert (fn this.trackItem item)}}>{{item.name}}</div>
{{/each}}Conclusion
Ember Octane modernizes the framework while maintaining backward compatibility. The combination of tracked properties, native classes, and Glimmer components makes Ember development more productive and enjoyable.
Start using Octane patterns in new code and gradually migrate existing components. The result is simpler, faster, and more maintainable code. Ember's commitment to stability without stagnation means you can adopt these improvements confidently.