Why Ember's Native Class Syntax Changes How You Write Components
Ember Octane's native class syntax replaces EmberObject.extend() with standard ES6 classes and @tracked decorators. This post walks through the practical differences, a worked form-component conversion, TypeScript gains, and the trade-offs you'll face during migration.
22 Feb 2026, 22:31 UTC

The Problem with EmberObject Components
If you've maintained an Ember application since before Octane, you know the friction: EmberObject.extend() with computed() properties, get()/set() everywhere, and TypeScript definitions that never quite matched runtime behavior. The mental model required remembering which properties were tracked, which needed notifyPropertyChange(), and why your IDE couldn't autocomplete template helpers.
Ember Octane (3.13+) replaced that model with native JavaScript classes and the @tracked decorator. The shift isn't cosmetic—it changes how you reason about reactivity, how TypeScript understands your code, and how easily components interoperate with non-Ember modules.
What Actually Changed
Pre-Octane components looked like this:
import Component from '@ember/component';
import { computed } from '@ember/object';
export default Component.extend({
firstName: '',
lastName: '',
fullName: computed('firstName', 'lastName', function() {
return `${this.get('firstName')} ${this.get('lastName')}`;
})
});
Post-Octane, the same component uses standard class syntax:
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
export default class PersonComponent extends Component {
@tracked firstName = '';
@tracked lastName = '';
get fullName() {
return `${this.firstName} ${this.lastName}`;
}
}
The differences are practical: no get()/set(), no string-based dependency keys, and the computed property becomes a native getter. Reactivity is opt-in via @tracked rather than opt-out via computed().
Tracked Properties vs. Computed Properties
The old model required you to declare every dependency explicitly. Miss one, and the UI stale-renders. The new model tracks property access during rendering. When a tracked property is read, Ember records that dependency. When it's written, Ember invalidates only the components that actually read it.
This eliminates an entire class of bugs: forgotten dependencies in computed() arrays. But it introduces a new mental model—you must understand when tracking occurs. Accessing a tracked property inside a lifecycle hook like didInsertElement won't create a template dependency, because the template isn't rendering there.
TypeScript Integration That Works
Native class fields are typeable directly. In the pre-Octane example, firstName and lastName lived in an untyped POJO passed to extend(). TypeScript saw any. With native classes:
@tracked firstName: string = '';
@tracked lastName: string = '';
get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}Your IDE now knows the exact types, catches typos in templates (via ember-template-lint with TypeScript support), and refactors safely across component boundaries. The @glimmer/component base class also provides typed args and signature for stricter component contracts.
Worked Example: Converting a Form Component
Suppose you have a login form with validation state. Here's the Octane conversion path:
- Generate a new component in an Octane project (Ember CLI 3.13+):
Run this in your project root. Requires write access toember g component login-formapp/components/. The generator emits a native class file by default. - Replace the old component (
app/components/login-form.js):import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; export default class LoginFormComponent extends Component { @tracked email = ''; @tracked password = ''; @tracked errorMessage = ''; get isValid() { return this.email.includes('@') && this.password.length >= 8; } @action async handleSubmit(event) { event.preventDefault(); this.errorMessage = ''; try { await this.args.onLogin(this.email, this.password); } catch (e) { this.errorMessage = e.message; } } } - Update the template (
app/components/login-form.hbs):<form {{on "submit" this.handleSubmit}}> <label>Email: <input type="email" value={{this.email}} {{on "input" (fn (mut this.email) value="target.value")}} /></label> <label>Password: <input type="password" value={{this.password}} {{on "input" (fn (mut this.password) value="target.value")}} /></label> {{#if this.errorMessage}} <div class="error">{{this.errorMessage}}</div> {{/if}} <button type="submit" disabled={{not this.isValid}}>Login</button> </form> - Verify: Run
ember serveand submit the form with invalid data. The button disables reactively as you type. Noset()calls, nocomputed()arrays.
Trade-offs and Limitations
- Addon compatibility: Some older addons extend
EmberObjector rely onEmber.computedinternals. They may need shims or updates. Check the addon's README for "Octane ready" or "Glimmer component" tags before adopting. - Learning curve for tracked: Developers accustomed to observers may over-track (marking everything
@tracked) or under-track (forgetting to mark a property that's mutated directly). The rule: mark the source of truth, not derived values. - No automatic deep tracking:
@trackedon an object or array tracks the reference, not mutations inside. Usetracked-built-ins(TrackedArray,TrackedMap) or immutable updates for nested changes.
How to Verify the Migration Works
- Create a fresh Octane project:
ember new my-app --octane(requires Node 14+ and npm/yarn). - Generate a component:
ember g component my-foo. Openapp/components/my-foo.js—it should export a class extending@glimmer/componentwith@trackedfields. - Add a tracked property and a getter, then toggle the property in the template via an
{{on "click"}}handler. The DOM updates withoutset(). - Run
ember typescript:install(if using TypeScript) and confirmtsc --noEmitpasses with strict mode enabled.
If all four steps succeed, your environment supports the native class model end-to-end.
What to Do Next
Start new components with native class syntax today. For existing components, convert incrementally—route-level components first, since they own the most state. Run the verification steps above on a feature branch before merging. The Ember community has largely standardized on this pattern; the sooner your codebase aligns, the less friction you'll hit with tooling, addons, and hiring.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.