skip to content

In Angular, when an array rendered by @for changes, how does the track key decide whether each row's view is kept, updated, moved or recreated?

level: middleimportance: must knowfreq 60%

answer

  1. old keys versus new keys
  2. Object.is on keys
  3. same key, new object: swap value
  4. moved nodes keep their state
  5. identity keys over fresh objects

basics

~20 s

Angular's @for matches old and new track keys: a matching key keeps the view and swaps in the new item, a key at a new position moves the view's DOM nodes, new keys create views, vanished keys destroy them.

solid answer

~50 s

On each change-detection pass the `@for` runtime reconciles the rendered rows against the new collection using the track keys, compared with `Object.is`. If a key still exists, its view is kept; when the item object is new but the key matches, Angular just replaces the row's item value so bindings refresh on the next check, with no DOM churn. If a key now sits at another position, the existing view's DOM nodes are detached and re-attached there, carrying input text and child component state with them (a moved node can lose focus, because the browser blurs a node that is detached). Keys that are new get fresh views; keys that disappeared have their views destroyed, running `ngOnDestroy` and cleanup. `$index` is then re-assigned. With `track item`, a refetch of new objects matches nothing, so every row is destroyed and recreated, which dev mode can report as `NG0956`.

code

ts · 28 lines
ts
import {Component, signal} from '@angular/core';

interface Player { id: string; name: string; score: number; }

@Component({
  selector: 'app-leaderboard',
  template: `
    <button (click)="resort()">Sort by score</button>
    <ol>
      @for (p of players(); track p.id) {
        <li>{{ p.name }} <input placeholder="note" /></li>
      }
    </ol>
  `,
})
export class Leaderboard {
  readonly players = signal<Player[]>([
    {id: 'a', name: 'Ada', score: 10},
    {id: 'b', name: 'Linus', score: 30},
    {id: 'c', name: 'Grace', score: 20},
  ]);

  resort(): void {
    this.players.update((list) =>
      [...list].sort((x, y) => y.score - x.score).map((p) => ({...p})),
    );
  }
}

go deeper

for a junior

Know that the key decides whether a row is reused, and that a unique id keeps rows and their state together when the list changes.

for a middle

Walk through the five outcomes for a key: untouched, value swapped, moved, created, destroyed, and show how $index and identity keys change which one happens.

for a senior

Tie symptoms to the key: state on the wrong row means position keys, lost state and slow refreshes mean identity keys over fresh objects, and name the dev warnings.

for a principal

Argue for stable ids in the data contract and immutable updates together, since id tracking makes immutable data cheap while identity tracking makes it expensive.

## The reconciliation step Each `@for` block keeps a **live collection**: the list of embedded views currently rendered, each remembering its item (the context's `$implicit`) and its `$index`. When change detection reaches the block and the collection expression yields a value, Angular runs a **reconcile** step that turns the old list of views into one matching the new collection. The only thing it uses to pair old rows with new items is the **track key**, and keys are compared with `Object.is`. For each item, the result falls into one of these cases: | Situation for a key | What Angular does | Row state (typed text, child component fields) | |---|---|---| | same key, same position, same object | nothing | kept | | same key, same position, new object | replaces the row's item value; bindings refresh | kept | | same key, different position | detaches the view's DOM nodes and re-attaches them at the new position | kept, travels with the row (focus may be lost by the DOM move) | | key not seen before | creates a new view (runs constructors, `ngOnInit`) | new, empty | | key no longer present | destroys the view (`ngOnDestroy`, `DestroyRef` callbacks) | gone | After the moves, Angular re-assigns `$index` on rows whose position changed, so `$first`, `$last`, `$even` and `$odd` stay accurate. ## Why the choice of key changes the outcome The same data change produces very different DOM work depending on the key. Take a leaderboard of three players re-sorted by score, with the objects replaced by fresh ones from the server: 1. **`track player.id`**: every key still exists. Angular moves the existing `<li>` nodes into the new order and swaps in the new player objects. Each row's state moves with its player. 2. **`track $index`**: keys are `0, 1, 2` before and after, so every key "matches" at its old position. No node moves; each row is handed a different player. Bound text updates, but anything held in the row itself (an expanded panel inside a child component, an uncontrolled input's value, focus) now sits next to a different player. 3. **`track player`**: the fresh objects are not `===` to the old ones, so no key matches. All three views are destroyed and three new ones created, losing all row state and paying the full creation cost. In development builds Angular warns about the third case with **`NG0956`** when identity tracking recreates the entire collection and the rows are more than a single bound text node, and about duplicate keys with **`NG0955`**. Both are console warnings, not errors, and neither appears in production builds. ## Reuse in place versus remount `@for` prefers **view reuse**. When the key matches but the object is new, Angular does not remount the row: it updates the item value, and the row's bindings, including component inputs, pick up the new data on the next check. The Angular docs also call out a subtler case: when an object at the same position is mutated in place so that its tracked property changes, `@for` still updates the bindings rather than destroying and recreating the element, whereas `*ngFor` with a `trackBy` returning a different value would remount it. Code that relied on a remount to reset a child component's state needs an explicit reset after migrating. ## Arrays and other iterables The collection may be any JavaScript iterable (`Array`, `Set`, `Map` entries, a generator result). Arrays get a faster path that compares from both ends and detects swaps without allocating; other iterables go through the general path, which uses temporary maps of detached views. The outcome is the same, only the cost differs. ## Consequences worth stating in an interview - **Moves are cheap and state-preserving** only when keys are stable identifiers. - **Replacing objects is fine** with id tracking: immutable updates cost a value swap, not a rebuild. - **Position keys are a correctness choice**, not only a performance one: they keep DOM nodes where they are and move the data instead. - **Destroy runs cleanup**: a row that disappears runs its components' `ngOnDestroy`, so subscriptions and effects owned by it end. - **The key must be unique**: duplicate keys make Angular unable to tell rows apart and may apply moves to the wrong nodes.

  • Does Angular's @for re-run a row component's ngOnInit when the item object is replaced but the track key is unchanged?
    No. The view is reused, so the component instance survives and `ngOnInit` does not run again. Angular only replaces the row's item value, and the component sees the new data through its input on the next check. Logic that must react to a new item belongs in a `computed()` or `effect()` over a signal input, or in `ngOnChanges` for a decorator input.
  • What do Angular's NG0955 and NG0956 warnings tell you about a @for loop?
    `NG0955` means the track expression produced duplicate keys, so rows cannot be told apart. `NG0956` means identity tracking (`track item`) caused the whole collection to be destroyed and recreated in one pass, typical of immutable data. Both are development-mode console warnings; the fix for each is a unique, stable key such as an id.

Tracking by id is a cloakroom that files coats by ticket number: whatever order people return in, each gets their own coat back. Tracking by $index files coats by hook position, so after a reshuffle the third person in line takes whatever hangs on hook three.

saying these in an interview costs you the question

  • Angular re-renders the whole @for list whenever the array reference changes
  • A matching key with a new object destroys and recreates the row
  • track $index moves DOM nodes when the list is re-sorted
  • Moving a row recreates its DOM nodes, so typed input text is lost
  • NG0956 is an error that breaks the production build