Ember 3.x — Adopting Octane
You can fully adopt Octane while staying on Ember 3.x. The Octane mental model — @glimmer/component, @tracked, modifiers, native classes — is the same one used in 4.x, 5.x, and Polaris. Doing the conversion now means the eventual major-version bumps become near-trivial.
This skill is the structured plan for converting a classic-3.x app to Octane-3.x. After this, the ember-3-to-4-migration skill takes over.
Prerequisites
- Ember 3.16+ (3.20+ is much smoother).
- jQuery integration disabled (
@ember/optional-features):pnpm exec ember feature:disable jquery-integration @glimmer/componentand@glimmer/trackinginstalled:pnpm add @glimmer/component @glimmer/tracking@ember/render-modifiersandember-modifierinstalled (for lifecycle bridging).- Tests on the modern API (
module + setupRenderingTest). Some Octane patterns can't be expressed in the classic test API.
Step 1 — Flip the optional features
config/optional-features.json:
{
"application-template-wrapper": false,
"default-async-observers": true,
"jquery-integration": false,
"template-only-glimmer-components": true
}
Each flag, in plain English:
| Flag | What it does |
|---|---|
application-template-wrapper: false |
The application's <div class="ember-application"> wrapper is gone. The template renders directly into the element you mounted into. |
default-async-observers: true |
Observers fire async (closer to render) instead of sync. Required for tracked-friendly behavior. |
jquery-integration: false |
jQuery isn't bundled. this.$() and Ember.$ go away. |
template-only-glimmer-components: true |
A template-only component (foo.hbs with no foo.js) renders as a Glimmer component (no wrapper element), not a classic one. This is the riskiest flag — every layout assumption based on the wrapper <div> may need fixing. |
Flip them one at a time in 3.x. Run the suite after each flip. The template-only-glimmer-components flip is the one that surfaces hidden CSS/layout dependencies — schedule a designer pass for that day.
Step 2 — Run the Octane codemods
The umbrella tool is ember-octane-codemods. Under the hood it's a sequence:
pnpm exec ember-cli-update --run-codemods
The order matters — and is typically:
ember-no-implicit-this-codemod—{{foo}}→{{this.foo}}for class-field references.ember-angle-brackets-codemod—{{user-card}}→<UserCard>.ember-on-modifier-codemod—onclick={{action "save"}}→{{on "click" this.save}}.ember-native-class-codemod—Component.extend({...})→class extends Component { ... }with decorators.ember-tracked-properties-codemod—computed,set/getcalls, observers →@tracked+ getters.
Run each, commit, run tests. Don't run all five at once.
Step 3 — Per-file conversion order (manual)
Codemods get you 70% of the way. The remaining 30% is per-file judgment. Order:
1. Pure-template components (no .js file) ← already Glimmer after step 1
2. Leaf components with simple state ← @tracked + @action
3. Components with @ember/render-modifier hooks ← keep as-is or extract real modifiers
4. Components with mixins ← extract mixin to service first
5. Components with computed properties + @each ← convert to getters reading @tracked
6. Components with classic actions: {...} ← @action methods
7. Layouts / app-wrappers ← last; they touch everything
For each file:
- Convert the class to
class extends Component. - Replace
extend({})second-arg behavior with class fields/methods. - Add
@trackedto anything the template reads or that gets set. - Add
@actionto methods invoked from templates or as callbacks. - Replace
actions: { foo() {} }with@action foo() {}. - Replace
this.set('foo', x)withthis.foo = x. (If that breaks reactivity, the field needs@tracked.) - Remove
_super(...arguments)— it's classic-only. - If converting from
@ember/component→@glimmer/component, removetagName/classNames/classNameBindings/attributeBindingsand use...attributeson the chosen root element of the template.
Do not mix dialects in one file. If you can't finish a conversion in the PR, leave the file classic.
Step 4 — Services first, components later (counter-intuitive but right)
Convert services to Octane before the components that use them. Why: an Octane component reading a non-tracked field on a classic service won't update. Once services are Octane, both classic and Octane consumers see updates.
// app/services/cart.ts
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class CartService extends Service {
@tracked items = [];
add(item) { this.items = [...this.items, item]; }
}
The classic consumers (Component.extend({ cart: service(), ... })) keep working — the service's @tracked field is observable through both APIs.
Step 5 — @cached for derived state
Once @tracked lands, getters that read tracked state are reactive. For expensive getters, add @cached from @glimmer/tracking:
import { cached, tracked } from '@glimmer/tracking';
class TodoList {
@tracked todos = [];
@cached
get sortedByDeadline() {
return [...this.todos].sort((a, b) => a.deadline - b.deadline);
}
}
Step 6 — The mixin extraction pass
Run this as a focused pass after the leaf components are converted:
- List every mixin in
app/mixins/. - For each: identify the consumers, group by intent.
- Extract to a service when the behavior is "shared state or shared dependency" (logging, audit, polling).
- Extract to a utility module when the behavior is "shared pure logic" (formatters, calculators).
- Extract to a class decorator only as a last resort.
- Convert each consumer site, one PR at a time.
Mixins are the single biggest blocker to a clean Octane state. Plan a quarter-week for this if you have many.
Step 7 — Modifier hygiene
After the codemods, you'll have a lot of {{did-insert this.fn}} calls courtesy of @ember/render-modifiers. That's fine as a transient state; long-term, write custom modifiers for the recurring patterns:
// app/modifiers/auto-focus.ts
import { modifier } from 'ember-modifier';
export default modifier((el) => el.focus());
{{!-- before --}}
<input {{did-insert this.focus}} />
{{!-- after --}}
<input {{auto-focus}} />
Aim to make {{did-insert}} rare, used only for one-off page-init hooks.
Step 8 — Routes and controllers can wait
Octane gives most of its benefits in components and services. Routes barely change. Controllers change only in that you'll narrow them to query-param holders.
A reasonable line: leave routes and controllers in classic-syntax extend({...}) form through 3.x, convert them to class extends Route { ... } during the 4.x migration. Doing it earlier pays off less and risks regressions in transition logic.
Step 9 — Templates: angle brackets and (fn) and {{on}}
After the codemods, lock in:
- All component invocations in angle brackets.
- All event handlers as
{{on "event" this.handler}}— neveronclick={{action ...}}. - Callbacks with arguments use
(fn this.handler arg)— never(action ...). - All references to class fields in templates start with
this..
Add a template-lint rule pass to catch regressions:
pnpm exec ember-template-lint --config-path .template-lintrc.js .
.template-lintrc.js:
module.exports = {
extends: ['octane'],
};
Step 10 — Component test conversion
Every component you Octane-port should also have its test file ported to setupRenderingTest. In 3.x, the matching test API is fully available, so do them in the same PR.
// before
moduleForComponent('user-card', 'Integration | Component | user-card', { integration: true });
// after
module('Integration | Component | user-card', function (hooks) {
setupRenderingTest(hooks);
test('...', async function (assert) {
await render(hbs`<UserCard @user={{this.user}} />`);
assert.dom('[data-test-name]').hasText('Ada');
});
});
What you do not do during Octane adoption
- Don't introduce TypeScript in the same window. TS+Octane is great; doing both in one go triples the risk.
- Don't introduce Embroider. Embroider arrives properly in 4.x. Trying to bolt it on during Octane conversion is a separate, riskier project.
- Don't remove ember-data. Octane works fine with classic ember-data. Save the ember-data refactor for the 4.x window.
- Don't ship template tag (
<template>) — that's Polaris-era. 3.x is not the time. - Don't refactor architecturally. Convert syntax. Save architectural changes for after the upgrade is done.
Verification — am I "Octane-adopted"?
- All optional features set as listed above.
-
template-lintpasses theoctaneruleset. - No
Ember.Component.extend(...)or@ember/componentimports in components. - No
actions: {...}hashes; all callbacks are@action. - All event handling uses
{{on}}and(fn ...). - All component invocations are angle brackets.
- All class-field template references use
this.. - Mixins extracted to services or utilities.
-
@ember/render-modifiersuses are documented; recurring patterns are real custom modifiers. - Tests for converted components on
setupRenderingTest. - No
(mut foo)in templates.
When that's all green, you're ready to start the 3.28 → 4.x jump.
See also
ember-3-mixed-classic-octane— the patchwork state during conversion.ember-3-to-4-migration— the next step.- Modern reference:
ember-octane-fundamentals.