Ember Autotracking in Practice: What @tracked Actually Buys You
Ember's autotracking replaces hand-maintained computed-property dependency lists with runtime tracking. A worked cart example shows the pattern — and the untracked-mutation trap that causes most 'why didn't it update?' bugs.
14 Jun 2026, 11:41 UTC

If you have ever shipped an Ember bug where a template refused to update because a computed property was missing one dependent key in a list of twelve, autotracking is the fix you were waiting for. The short version: with @tracked, Ember figures out dependencies at runtime by watching what your code actually reads, so the hand-maintained dependency lists of the classic computed() era go away entirely.
The problem with declared dependencies
Classic computed properties required you to enumerate every piece of state they consumed: computed('[contact removed]', 'taxRate', ...). That string was a promise you made to the framework, and it was easy to break. Add a new input to the calculation, forget to extend the key list, and the property silently serves stale values. The failure mode is nasty precisely because nothing throws — the UI just lies.
Autotracking inverts the responsibility. Instead of you declaring what a computation depends on, Ember records what it reads while it runs. Any tracked state touched during a getter or template render is automatically subscribed to. When that state changes later, only the consumers that actually read it are dirtied and re-rendered.
A worked example: the shopping cart
Assume an Octane-era app (Ember 3.15 or later, where @tracked is imported from @glimmer/tracking with no extra setup). Here is a cart component with derived state and zero dependency declarations:
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class CartComponent extends Component {
@tracked items = [];
@tracked taxRate = 0.2;
get subtotal() {
return this.items.reduce((sum, item) => sum + item.price, 0);
}
get total() {
return this.subtotal * (1 + this.taxRate);
}
@action
addItem(item) {
// Replace the reference so the tracked field notifies consumers.
this.items = [...this.items, item];
}
}The template reads {{this.total}}. During that render, Ember sees the read of total, which reads subtotal, which reads items and taxRate. Both tracked fields are now subscribed. When addItem reassigns this.items, the getters re-run and the template updates — no observers, no @each key paths, no notifyPropertyChange.
Notice the deliberate choice in addItem: it builds a new array rather than pushing into the existing one. That is not a stylistic preference, and the next section explains why.
The trade-off: untracked mutation is invisible
Autotracking's sharp edge is the mirror of its convenience. Only reads of tracked state are recorded. If you store a plain array in a @tracked field and then call this.items.push(item), the field's reference never changes, nothing is dirtied, and your template stays stale. This is the single most common "why didn't my component update?" bug in modern Ember codebases.
The same applies to plain (non-decorated) properties: reassigning this.debugLabel = 'x' on a component will not re-render anything that read it. And deep mutation of nested plain objects — this.settings.theme.color = 'red' — is invisible unless the state is wrapped appropriately.
The established patterns for dealing with this:
- Prefer immutable updates: reassign tracked fields with new references (
[...arr, x],{...obj, key: val}). This is simple, explicit, and works everywhere. - For state that is naturally mutated in place, use tracked collection wrappers such as
TrackedArrayandTrackedObjectfrom thetracked-built-insaddon, which makepushand property assignment reactive. - Route all mutable state through tracked primitives. Mixing tracked and untracked mutable state in one component is where the confusion breeds.
Why it still beats computed properties
Despite the discipline required, autotracking removes an entire class of stale-dependency bugs that computed properties made routine. Dependencies can no longer drift out of sync with the implementation, because there is nothing to drift — the dependency set is the implementation. It also composes with plain JavaScript: native classes, standard getters, and ordinary functions all participate without EmberObject.extend, which makes Ember code easier to share, test, and read for anyone coming from outside the framework.
Qualitatively, updates also stay fine-grained: a change dirties only the getters and template regions that consumed the changed value, rather than invalidating broad computed chains. (Exact performance characteristics depend on your app; measure rather than assume.)
Checking your own code
A practical verification habit: when a template fails to update, ask two questions. First, is the state being read actually marked @tracked (or wrapped in a tracked built-in)? Second, is the mutation replacing the reference, or mutating in place? In the cart example above, you can confirm the behavior directly: addItem updates the rendered total, while this.items.push(item) in a scratch action does not. If you are on an older Ember version, confirm @glimmer/tracking resolves before adopting the pattern — decorator availability is version-sensitive, so check the guides for your target release.
The actionable rule to take away: make state tracked, make updates immutable by default, and reach for tracked collections only when in-place mutation is genuinely the better model. Do that, and the "stale template" class of bug mostly disappears from your day.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.