The track Function in Angular: Where It Helps and Where It Doesn't
In a previous piece on Senior Angular Interview Questions, I touched on optimizing for loops with the track method. But I also stressed that the value you choose for tracking makes all the difference. Simply adding track does not automatically improve rendering performance—if the key is chosen poorly, the optimization falls apart. This article digs deeper into that point with more context and concrete examples.
The track function is a performance feature that developers frequently skipped when working with the older *ngFor="let item of items" syntax. The newer control flow syntax @for() now mandates a track function, which nudges everyone toward more disciplined patterns.
Why does this matter? Picture a component that pulls a list of users from an API and renders it in the template. There's also a "reload" button that refetches the same data. Here's what that looks like with the legacy *ngFor approach:
@Component({
selector: 'app-child',
imports: [NgForOf],
template: `
<button (click)="onRerun()">re run</button>
<div *ngFor="let item of items()">
{{item.name}}
</div>
`
})
export class ChildComponent {
items = signal<{ id: string; name: string }[]>([]);
onRerun() {
// "fake api call" to reload data
this.items.set([{id: '100', name: 'Item 1'}, /* ... */ ]);
}
}
In this scenario, each time onRerun() fires and the array gets reassigned—even with content that hasn't changed—Angular recreates every DOM element. It cannot determine which entries remained the same, so it rebuilds all of them. This leads to degraded performance and visible flickering, especially with longer or more intricate lists. The fix involves adding a trackBy function:
@Component({
selector: 'app-child',
imports: [CommonModule],
template: `
<ng-container *ngFor="let item of items(); trackBy: identify">
<!-- previous code -->
</ng-container>
`
})
export class ChildComponent {
// ... previous code
identify(index: number, item: { id: string }): string | number {
return item.id;
}
}
By supplying this, Angular learns how to distinguish each item in the array—usually through an id property. When a trackBy function is provided (or a track key in @for()), Angular maps each item to its existing DOM node. On data reload, it compares these keys rather than full object references, so unchanged entries remain untouched in the DOM.
The reason this matters is straightforward: DOM manipulation is costly. Without tracking, Angular tears down and rebuilds all elements on every change, regardless of whether the underlying data shifted. With tracking, existing nodes are reused, and only the bindings that actually changed get updated.
Take a look at the GIF below. The top list relies on trackBy: identify, while the bottom one has no tracking. The distinction is clear: when data reloads, the top list keeps its DOM nodes, whereas the bottom list redraws everything from scratch.
With the new @for() syntax, Angular forces a track key to be specified. Even so, developers commonly slip into two traps:
- Tracking by the object itself—for instance,
@for (item of items(); track item). This falls short because object references change with every fetch, even when the data is identical, so the view gets refreshed every time and thetrackfunction becomes pointless. - Tracking by
$index—for example,@for (item of items(); track $index). This introduces problems during deletions. If you remove the 5th element from a 10-item list, every entry after index 4 shifts to a new index, prompting Angular to repaint them all without need. For stateful components—like forms—this can reset focus or cursor placement; although$indexis acceptable for lists that never change.
In the comparison below, the top usage relies on track item.id, while the bottom one uses track $index. Notice how the first approach keeps DOM elements intact when items are removed. You can experiment with this in a stackblitz example.
For a static set of rows in a table, or a fixed list with a stable order and size, $index works fine because an item's position never changes. In such cases, there's no danger of losing focus, cursor position, or component state, since nothing gets reordered or removed. Reserve $index for entirely static, non-interactive lists. When dealing with anything dynamic or stateful, choose a consistent identifier such as id.
A scenario where proper tracking proves essential is an infinite scroll or chat interface. Suppose you're building a chat window. New messages append to the list as they arrive, and scrolling up triggers older messages to load from the server.
Without a reliable track key (like message.id), Angular redraws the whole list every time new data is added. The scroll position jumps, the reader loses their spot, and ongoing animations get interrupted.
With a unique tracking key (such as an ID), Angular keeps existing DOM nodes for older messages and creates new ones only for incoming entries. This keeps the scroll position stable, preserves animation flow, and makes the app feel much more polished. The example below demonstrates an incorrect @for usage—tracking by $index forces the entire list to rerender with each new message.
@Component({
selector: 'app-chat',
standalone: true,
template: `
@for(msg of messages(); track $index){
<div>
{ msg.user }}: {{ msg.text }}
</div>
}
<button (click)="loadOlder()">Load older</button>
`,
})
export class ChatComponent {
readonly messages = signal([
{ id: 1, user: 'Alice', text: 'Hello' },
{ id: 2, user: 'Bob', text: 'Hi' },
]);
loadOlder() {
const older = [
{ id: 0, user: 'System', text: `Message: ${this.messages().length}` },
];
this.messages.set([...older, ...this.messages()]);
}
}



