Angular @for and track Explained: Stop Recreating the DOM [2026]
You refresh a list of 200 products from the API, and the whole list flickers: scroll position jumps, an open dropdown closes, a half-typed input loses its text. Nothing about the data changed except that it came back as new objects. The cause is almost always one expression: the track in your @for loop.
This is lesson 8.3 of the Angular Tutorial. Lesson 8.2 covered which components get checked; this lesson covers what happens to the DOM once they are. You'll see what track controls, why track item and track $index both cause trouble, how to read Angular's warnings about it, and how to migrate *ngFor with trackBy. The @for syntax itself was introduced in lesson 2.2.
What track actually does #
When a list changes, Angular has to decide, for each item, whether it's the same item as before (keep its DOM node and move it if needed) or a different one (destroy the old node, create a new one). track is the key it uses to decide:
| New list item's key… | Angular… |
|---|---|
| Matches an old item's key | Reuses that DOM node, updates its bindings, moves it if the position changed |
| Matches no old key | Creates a new DOM node (and component instances inside it) |
| (old key no longer present) | Destroys the old DOM node |
Reusing is cheap. Creating and destroying is expensive, and it throws away everything living in that DOM: focus, text in inputs, scroll position inside the item, CSS transition state, and the state of any child components. So the goal is simple: the key should identify the same real-world thing across updates.
@for (product of products(); track product.id) {
<app-product-row [product]="product" />
} @empty {
<p>No products yet.</p>
}
@for requires a track expression. Angular made it mandatory precisely because the old *ngFor default caused so many silent performance problems.
Trap 1: track item (tracking by object identity) #
@for (product of products(); track product) { … }
Tracking the object itself works as long as the objects are never replaced. But most data is: an HTTP refetch, an httpResource reload, a store update that returns new objects, or an immutable update like list.map(p => p.id === id ? { ...p, qty } : p). Every one of those produces new object references for the same products, so every key is new, and Angular destroys and recreates every row.
With signals and immutable updates now the recommended style (lesson 8.1), track item is the wrong default: the better your state management, the worse it performs.
Trap 2: track $index (tracking by position) #
@for (task of tasks(); track $index) { … }
This avoids re-creation, since position 0 is always position 0, but it's wrong whenever the list reorders, inserts or deletes anywhere but the end. Delete the first task and every row shifts: row 0's DOM node is reused for what used to be task 2, row 1's for task 3, and so on. Bindings update, so the text looks right, but anything the DOM or a child component was holding, such as an input's typed value, a focused element, an expanded panel or a playing animation, stays with the position, not the task. Users see state jump to the wrong item.
track $index is fine for lists that are static, or only ever appended to, and have no per-item state. For anything else, use a stable ID.
Choosing a good key #
| Data | Good track |
Why |
|---|---|---|
| Records from an API or database | item.id |
Stable across refetches and immutable updates |
| Items with a natural unique field | item.slug, item.email, item.sku |
Same, if it's truly unique and doesn't change |
| Composite identity | item.orderId + ':' + item.lineNo |
Build a unique string when no single field is unique |
Static list of primitives (['S','M','L']) |
size or $index |
Values are the identity; nothing reorders |
| Client-created items with no ID yet | Assign one on creation (crypto.randomUUID()) |
Never rely on position for editable rows |
Two rules make keys safe: they must be unique within the list, and stable for the life of the item. A key that changes when the user edits a field (like tracking by name) turns every edit into a destroy-and-create.
Let Angular tell you #
In development mode, Angular warns about both classic mistakes in the browser console:
- Duplicate keys (
NG0955): two items produced the sametrackvalue. Angular can't tell them apart, so reuse becomes unpredictable. Fix the key; don't ignore the warning. - Whole-collection re-creation (
NG0956): a tracking expression caused every item to be recreated on an update, which is the classic symptom oftrack itemwith freshly fetched objects. Switch to an ID.
Both are worth treating as bugs even when the page looks fine, because they usually show up as lag or lost input only on real data sizes.
Measuring the difference #
Two quick ways to see whether rows are being reused:
- DevTools → Elements: expand the list, then refresh the data. Nodes that flash as newly inserted (Chrome highlights changed nodes) were recreated; reused nodes only have changed attributes or text.
- A lifecycle counter: in the row component, count creations:
let created = 0;
export class ProductRow {
constructor() { console.debug('ProductRow created', ++created); }
}
Refresh a 200-item list. With track product.id the count stays flat after the first render; with track product it jumps by 200 on every refresh. The Angular DevTools profiler (lesson 8.2) shows the same thing as time spent creating components.
Migrating from *ngFor and trackBy #
The old syntax needed a function on the class; @for puts the expression in the template:
// Before
trackById(index: number, product: Product) { return product.id; }
<!-- Before -->
<div *ngFor="let product of products; trackBy: trackById; let i = index">…</div>
<!-- After -->
@for (product of products(); track product.id; let i = $index) {
<div>…</div>
}
The official control-flow migration (ng generate @angular/core:control-flow) converts the syntax. It carries an existing trackBy function over, but where a loop had no trackBy, it has to pick something, so review each loop it touched and replace identity or index tracking with a real key where the data can change. *ngFor without trackBy tracked by identity, which is exactly Trap 1.
The implicit variables carry over with a $ prefix: $index, $count, $first, $last, $even and $odd. Alias them with let when nesting loops, so an inner $index doesn't shadow the outer one.
track and OnPush together #
track and OnPush solve different halves of list performance:
- OnPush (8.1, 8.2) decides whether a row component's template is re-checked.
trackdecides whether a row's DOM and component instance survive the update at all.
A reused OnPush row whose product input is a new object with identical data is still re-checked, because its input reference changed. That's cheap. A recreated row pays for construction, rendering and destruction. So get track right first; it's the bigger win, and it makes OnPush's per-row savings actually apply.
Gotchas #
| Symptom | Cause | Fix |
|---|---|---|
| Whole list flickers on refetch | track item with new objects |
track item.id |
| Typed text or open state jumps to another row after delete | track $index on a mutable list |
Track a stable ID |
NG0955 duplicate keys warning |
Key not unique (e.g. tracking name) |
Use a unique field or composite key |
NG0956 re-creation warning |
Identity tracking, fresh objects | Track by ID |
| Editing a field recreates the row | Key includes a field the user edits | Track an immutable identifier |
| New items without IDs misbehave | Client-created rows tracked by index | Assign an ID on creation |
What's next #
Lesson 8.4 moves from rendering to loading: NgOptimizedImage, the built-in directive that fixes most image-related Largest Contentful Paint problems with a one-attribute change. Later, 8.8 combines OnPush, track, signals and computed() into one playbook for avoiding unnecessary re-renders.
Try it yourself #
Render 200 products with a row component that logs its creations. Refresh the data from a mock API every few seconds and compare track product, track $index and track product.id. Then add an input to each row, type into row 3, delete row 1, and watch where your text goes under each option.
search_documentationNG0956 means the tracking expression caused Angular to recreate the entire collection. Your table loops with @for (order of orders(); track order), and each refresh from httpResource returns new order objects, so every key is new and all rows are destroyed and rebuilt. Change it to track order.id. Rows for orders that still exist will then be reused, and only new or removed orders create or destroy DOM. While you’re there, the order-line loop inside each row tracks $index; line items can be reordered in that view, so track line.id there too.Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


