Skip to content
All essays
WebJanuary 15, 202613 min

Ember Octane: Modern Reactivity and Component Patterns

Explore Ember Octane edition features including tracked properties, native classes, decorators, and modern component patterns

Ü
Ümit Uz
Mobile & Full Stack Developer

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:

javascript
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:

javascript
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:

javascript
import { TrackedObject } from '@glimmer/tracking';

const user = new TrackedObject({
  name: 'John',
  email: 'john@example.com'
});

// Update tracked
user.name = 'Jane'; // Triggers updates

Tracked Maps and Sets

javascript
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

javascript
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

javascript
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

javascript
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

javascript
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

javascript
import { tracked } from '@glimmer/tracking';

export default class CounterComponent extends Component {
  @tracked count = 0;

  @action
  increment() {
    this.count++;
  }
}

Actions

javascript
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:

javascript
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

handlebars
{{! app/components/avatar.hbs }}
<div class="avatar">
  <img src={{@src}} alt={{@alt}} />
</div>

No JavaScript file needed!

JavaScript Components

javascript
// 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

javascript
// 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();
  }
});
javascript
// 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();
    }
  }
}
handlebars
{{! app/components/chart.hbs }}
<div {{did-insert this.setupChart}} {{will-destroy this.teardownChart}}>
  {{yield}}
</div>

Async Actions

Modern async/await in actions:

javascript
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;
    }
  }
}
handlebars
<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:

javascript
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:

javascript
// 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)

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

javascript
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

  1. 1Use Tracked Properties: Always use @tracked for reactive state
  2. 2Prefer Getters: Use getters instead of computed properties
  3. 3Template-Only Components: Use them when possible
  4. 4Decorators: Leverage decorators for cleaner code
  5. 5Async/Await: Use modern async patterns
  6. 6Native Classes: Use native JavaScript classes everywhere

Performance Tips

Avoid Tracked When Not Needed

javascript
// Bad - tracked but never changes
@tracked staticConfig = { apiUrl: 'https://api.example.com' };

// Good - regular property
staticConfig = { apiUrl: 'https://api.example.com' };

Memoize Expensive Computations

javascript
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

handlebars
{{#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.

Enjoyed this essay? Share it.

Related essays

Next essay
Building Recommendation Engines