skip to content

In Angular's @for block, why is the track expression mandatory, and what should you usually pass to it?

level: juniorimportance: must knowfreq 78%

answer

  1. compile error without it
  2. one key per row
  3. stable unique field first
  4. $index only for static lists
  5. item reference as last resort

basics

~20 s

Angular's @for requires track so the runtime can match each item to its existing row view by key. Track a stable unique field like item.id, use $index only for static lists, and the item itself only as a last resort.

solid answer

~40 s

A `@for` without `track` does not compile: the template parser reports `@for loop must have a "track" expression`. The expression yields one key per item, and on every change-detection pass Angular compares the old keys with the new ones to decide which row views to keep, move, create or destroy, so the DOM work stays small and per-row state stays with its data. The usual choice is a stable, unique property such as `item.id`. `track $index` is acceptable for a static list that never reorders, inserts or removes items. `track item` compares object references with `===`, so a refetch that returns fresh objects rebuilds every row. Making the key mandatory was deliberate: with `*ngFor` the `trackBy` function was optional and routinely forgotten.

code

html · 3 lines
html
@for (order of orders(); track order.id) {
  <app-order-row [order]="order" />
}

go deeper

for a junior

Recall that @for will not compile without track, and name the three usual choices: a unique id, $index, or the item itself, in that order of preference.

for a middle

Explain that the key drives keep, move, create and destroy decisions, and why identity tracking rebuilds rows when data arrives as new objects.

for a senior

Show you have seen a wrong key in production: state on the wrong row after a sort, a full rebuild after each refetch, and the NG0955 and NG0956 dev warnings that point at them.

for a principal

Frame the key as a data-model concern: lists without stable ids push cost into every template, so ask for ids in the API contract rather than patching loops.

## What the track expression is Angular's built-in control flow (available since v17) repeats a block of template with `@for (item of items; track <expression>) { ... }`. Each repetition is an **embedded view**: a small tree of DOM nodes plus any directives, components and bindings declared inside the block. The **track expression** is evaluated once per item and produces a **key**. Angular stores the key of every rendered row and, on the next change-detection pass, compares it with the keys of the new collection. That comparison is how Angular decides, for every row: - **keep** the existing view and only refresh its bindings (same key, same place); - **move** the existing view's DOM nodes to a new position (same key, new place); - **create** a new view (a key that was not there before); - **destroy** a view (a key that is gone). Keys are compared with `Object.is`, so a number `42` and a string `'42'` are different keys. ## Why the compiler makes it mandatory The older `*ngFor` directive (from `NgForOf`, deprecated since v20) had an optional `trackBy` function. When it was omitted, `NgForOf` fell back to object identity, which is correct for mutable arrays but rebuilds every row when an HTTP call or a store returns a new array of new objects. A forgotten `trackBy` was a common cause of slow refreshes and of rows losing their input state. `@for` removes the choice: if `track` is missing the template does not compile, and the parser reports `@for loop must have a "track" expression`. You are forced to state which property identifies an item, at the moment you write the loop. ## Choosing the key | Track expression | Key is | Use it when | Cost when data changes | |---|---|---|---| | `track item.id` | a stable, unique field | almost always | minimal: rows are kept, moved or updated | | `track $index` | the row position | a static list that never reorders, inserts or removes | rows stay at positions and receive new data | | `track item` | the object reference (`===`) | nothing else identifies the item | a fresh array of fresh objects recreates every row | The Angular docs put it in the same order: pick a uniquely identifying property (commonly `id` or `uuid`), and if your data has none, strongly consider adding one; use `$index` for static collections; track the item itself only if no other option exists. Two rules about the key matter in practice: 1. **It must be unique within the collection.** Duplicate keys make the dev-mode build log warning `NG0955`, and Angular may pick the wrong DOM nodes when it moves or destroys rows. 2. **It must be stable across refreshes.** A key computed from something that changes on every fetch (a timestamp, a random value) makes every row look new. ## What a track expression may reference The track expression is compiled into a small function that runs outside the row's template scope, so it has deliberate limits: - it may read the **loop item**, **`$index`** (or its alias) and **members of the component**, so `track item.id` and `track trackById($index, item)` both work; - it may not read other contextual variables such as `$first` or `$count`, template reference variables, `@let` declarations or an outer loop's item; the type checker reports that only the item, `$index` and component properties are available; - it may not use pipes: `Cannot use pipes in track expressions` is a template parse error; - a `@for` may have only one `track` clause. ## A minimal example A component in current Angular (standalone by default, signals for state): ```ts import {Component, signal} from '@angular/core'; interface Player { id: string; name: string; score: number; } @Component({ selector: 'app-players', template: ` <ol> @for (player of players(); track player.id) { <li>{{ player.name }}: {{ player.score }}</li> } </ol> `, }) export class Players { readonly players = signal<Player[]>([]); } ``` When `players` is replaced by a new array whose objects carry the same `id`s, Angular keeps every `<li>` and only swaps in the new item values; when the order changes it moves the existing nodes. ## Common mistakes - Writing `track $index` everywhere "to make the error go away", then re-sorting or deleting from the list: rows keep their position and receive other items' data, so any state held in the row (focus, an uncontrolled input's text, a child component's internal state) ends up on the wrong item. - Writing `track item` over immutable data from a server: every refresh destroys and recreates all rows, which the dev build can flag with the warning `NG0956` when the rows are more than a bound text node. - Tracking by a display field such as `name` that can repeat, which triggers `NG0955`.

  • Can an Angular @for track expression call a method on the component?
    Yes. `track trackById($index, item)` works, and so does any expression that reads the loop item, `$index` and component members. It may not read other contextual variables such as `$first`, template references, `@let` values or an outer loop's item, and it may not contain a pipe. In practice `track item.id` inline is simpler than a method.
  • What happens in Angular when two items in a @for collection produce the same track key?
    In development mode Angular logs warning `NG0955` listing the duplicated keys and their indexes. Beyond the warning, the loop can no longer tell those items apart, so it may reuse or move the DOM nodes of the wrong one, and internally it has to fall back to slower data structures. The fix is a key that is unique within the collection.

saying these in an interview costs you the question

  • track is optional in @for, just like trackBy in *ngFor
  • track $index is the safe default for every list
  • track item is the recommended choice because it needs no id
  • The track key only affects performance, never which row keeps its state
  • A track expression can use a pipe to format the key