Tracking in @for & trackBy
When Angular renders a list, it needs a way to know which item maps to which DOM node. If it cannot establish a stable identity, any change to the array forces it to tear down and rebuild rows from scratch — destroying components, re-running constructors, and discarding focus and scroll state. A correct track expression in @for (or trackBy in the legacy *ngFor) tells Angular how to match old items to new ones, turning a full rebuild into a few cheap moves. This is one of the highest-leverage runtime optimizations in any list-heavy app.
Why identity matters
Angular reconciles a list by comparing the previous collection with the new one. By default it tracks each item by reference (object identity). That works until the data is replaced — for example, after an HTTP refresh that returns brand-new objects with identical values. Even though the rows look the same to a user, every reference differs, so Angular concludes the entire list changed and recreates all of it.
The symptoms are familiar: inputs lose focus mid-typing, images flicker as they reload, CSS transitions restart, and the profiler shows component constructors firing on every keystroke. A stable track key — usually a primary id — eliminates this churn.
@for (user of users(); track user.id) {
<app-user-row [user]="user" />
} @empty {
<p>No users found.</p>
}
Here track user.id tells Angular that two items are “the same row” when their id matches, regardless of object reference. On the next render it reuses the existing <app-user-row> instances and DOM, only updating bindings, inserting new ids, and removing absent ones.
In Angular 17+ the
trackexpression is mandatory for any@forover a mutable collection — the compiler emits a diagnostic if you omit it. This is deliberate: forgetting to track is the most common list performance bug, so the new control flow makes it impossible to overlook.
Measuring the difference
Consider a 1,000-row table refreshed every few seconds. The numbers below come from the Angular DevTools profiler comparing the same component with and without tracking.
Output:
Refresh strategy DOM nodes recreated CD time per refresh
No track (by reference) 1,000 41.2 ms
track item.id 3 2.8 ms
Tracking by id keeps the cost proportional to what actually changed, not to the size of the whole list.
Choosing a track expression
Pick the most stable, unique value available. The expression has access to both the item and its index.
| Track expression | Use when | Caveat |
|---|---|---|
track item.id | Items have a stable unique id | The ideal case — always prefer this |
track item | Object references are preserved across renders | Breaks if data is re-fetched into new objects |
track $index | Primitive lists with no id; order is stable | Re-creates rows when items are inserted/reordered |
track item.email | Id is absent but another field is unique | Field must be truly unique and immutable |
Avoid track $index for lists that get reordered, filtered, or have items inserted in the middle — index identity shifts and you lose the benefit entirely. Reserve it for append-only or read-only primitive lists.
The legacy *ngFor equivalent
If you are on an older codebase still using the structural directive, the same concept is expressed with trackBy, which takes a function returning the identity key:
import { Component } from '@angular/core';
interface User { id: number; name: string; }
@Component({
selector: 'app-user-list',
template: `
<ul>
<li *ngFor="let user of users; trackBy: trackById">{{ user.name }}</li>
</ul>
`,
})
export class UserListComponent {
users: User[] = [];
trackById(index: number, user: User): number {
return user.id;
}
}
The @for syntax inlines this logic, so migrating from *ngFor with trackBy to @for ... track is a near-mechanical rewrite and removes the boilerplate method.
Combining tracking with signals and OnPush
Tracking solves which DOM to reuse; signals and OnPush solve when to check at all. They compose. Drive the list from a signal so updates are fine-grained, and replace the array immutably so change detection notices the new reference:
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
interface Todo { id: number; title: string; done: boolean; }
@Component({
selector: 'app-todos',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (todo of todos(); track todo.id) {
<label>
<input type="checkbox" [checked]="todo.done" (change)="toggle(todo.id)" />
{{ todo.title }}
</label>
}
`,
})
export class TodosComponent {
protected readonly todos = signal<Todo[]>([
{ id: 1, title: 'Add track expressions', done: false },
{ id: 2, title: 'Enable OnPush', done: false },
]);
toggle(id: number): void {
this.todos.update(list =>
list.map(t => (t.id === id ? { ...t, done: !t.done } : t)),
);
}
}
Because track todo.id is stable, toggling one item only re-renders that single row even though update produces a brand-new array.
Best Practices
- Always supply a
trackexpression in@for; the compiler now requires it for mutable collections. - Track by a stable, unique id rather than by object reference, especially when data is re-fetched.
- Reserve
track $indexfor append-only or read-only primitive lists that never reorder. - When refreshing from an API, ensure your id field is consistent across responses so rows are reused.
- Migrate
*ngForwithtrackByto@for ... trackto drop boilerplate and gain compiler checks. - Pair tracking with
OnPushand signals, updating arrays immutably so change detection fires correctly. - Verify the win in the Angular DevTools profiler — watch that DOM recreation scales with the delta, not the list size.