skip to content

In an Angular leaderboard using @for (p of ranked(); track $index), why do expanded panels and typed notes stay at old positions after a re-sort, and how do you fix it?

level: seniorimportance: should knowfreq 42%

answer

  1. keys are positions
  2. data moves, nodes stay
  3. state lives in the row view
  4. key by player id
  5. or lift state into data

basics

~20 s

With track $index every position key still matches after a sort, so Angular keeps each row view in place and hands it another player; view state stays behind. Track p.id so views move with players.

solid answer

~50 s

`track $index` makes the key the row position. After the re-sort, positions 0 to n still exist, so Angular's reconcile finds every key matching, moves no DOM nodes and just swaps a different player into each row. Bound text such as name and score updates, but state that lives in the row view does not follow: a child component's `expanded` signal, an unbound `<input>`'s value, focus, a running CSS transition. The fix is `track p.id`, which makes Angular move the existing views to follow their players. Two traps to check while fixing it: tracking by a field that can repeat, such as `name`, triggers `NG0955`, and `track p` over fresh objects rebuilds every row (`NG0956`) and loses the state entirely. For state that must survive even a rebuild, store it by id outside the row.

code

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

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

@Component({
  selector: 'app-leaderboard',
  template: `
    <ol>
      @for (p of ranked(); track p.id) {
        <li>
          <button (click)="toggle(p.id)">{{ p.name }}: {{ p.score }}</button>
          @if (expanded().has(p.id)) {
            <p>Details for {{ p.name }}</p>
          }
        </li>
      }
    </ol>
  `,
})
export class Leaderboard {
  readonly players = input.required<Player[]>();
  readonly ranked = computed(() => [...this.players()].sort((a, b) => b.score - a.score));
  readonly expanded = signal<ReadonlySet<string>>(new Set());

  toggle(id: string): void {
    this.expanded.update((s) => {
      const next = new Set(s);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return next;
    });
  }
}

go deeper

for a junior

Recognise that track $index ties rows to positions, and that tracking by a unique player id makes rows follow their data after a sort.

for a middle

Explain that position keys all match after a sort, so views are reused in place with new items, and list which kinds of state stay behind.

for a senior

Diagnose from the symptom, pick a unique stable key, rule out name and identity keys with NG0955 and NG0956 in mind, and prove the fix with a test.

for a principal

Decide which row state deserves to live in data keyed by id so correctness never depends on view reuse, and set a review rule against position keys.

## What the symptom tells you A ranked list is re-sorted, and the names and scores move correctly, but row-local UI does not: an expanded "match history" panel that was open under the leader is now open under whoever took first place's slot, and a note typed next to one player now sits beside another. The data is right and the **state is attached to positions**. In an Angular `@for`, that is the signature of **`track $index`**. ## Why it happens The `@for` runtime pairs old rows with new items by **track key**. With `track $index` the keys are simply `0, 1, 2, ...`: 1. Before the sort the rows have keys `0..n-1`. 2. After the sort the new collection also produces keys `0..n-1`. 3. Every key matches at its own position, so reconcile **moves nothing**. It only replaces each row's item value with the player now at that index. 4. Bindings such as `{{ p.name }}` and `[player]="p"` refresh on the next check, so the text looks right. Everything that lives in the **row's view** rather than in the bound data stays at the position: - internal state of a child component, such as an `expanded = signal(false)` inside `<app-player-row>`; - the value of an `<input>` that is not bound to the model; - DOM focus, text selection and scroll position inside the row; - CSS classes toggled imperatively, or a transition in progress. Because the view was reused, the child component's constructor and `ngOnInit` do not run again; it just receives a new `player` input. ## The fix **Key by identity from the data**: ```html @for (p of ranked(); track p.id) { <app-player-row [player]="p" /> } ``` Now the keys before and after the sort are the same set of ids in a different order, so Angular **moves the existing views' DOM nodes** into the new order and swaps in the updated player objects. Each row's internal state travels with its player, and `$index` is re-assigned so rank numbers stay right. Check the key before shipping: | Key | Result after a sort | Dev-mode signal | |---|---|---| | `track $index` | nodes stay, data moves, row state mismatched | none | | `track p.name` | works until two players share a name, then rows can be confused | `NG0955` (duplicate keys) | | `track p` | fresh objects from a refetch match nothing, every row rebuilt, state lost | `NG0956` (full re-creation) | | `track p.id` | nodes move with their players, state preserved | none | ## When to also lift the state out of the row Even with a correct key, row state dies when the row is destroyed: a player dropping off the top 50 and coming back gets a new view. If "expanded" or a draft note must survive that, keep it **in data**, keyed by id, in the parent or a service: - a `signal<ReadonlySet<string>>` of expanded player ids; - a map of draft notes by id, bound into the row with an input. The row then derives its state from inputs, and the track key only affects performance, not correctness. ## How to verify 1. Reproduce with the DevTools Elements panel open: with `track p.id` the `<li>` nodes visibly reorder; with `track $index` they do not. 2. Type into a row's unbound input and trigger the sort; the text should stay beside the same player. 3. Add a component test that expands one player, re-sorts, and asserts the expanded panel is under the same player name. 4. Watch the console in a development build for `NG0955` or `NG0956` after the change. ## Why this is easy to ship `track $index` compiles, removes the "missing track" error, and behaves perfectly on a list that never reorders. The bug only appears once sorting, filtering, insertion or deletion is added, often months later, and only for state not held in bound data.

  • In this Angular leaderboard, why not simply use track p so the row follows the object?
    If the scores arrive as new objects on each refresh, no old object is `===` to a new one, so identity keys match nothing. Every row is destroyed and recreated, the expanded state is lost anyway, and the development build warns with `NG0956`. Identity tracking only works when the same object instances survive refreshes.
  • Is track $index ever the right choice for an Angular @for?
    Yes, for a static list that is never reordered, filtered, inserted into or deleted from, such as fixed table headers or a constant menu. There the positions are the identity, and index tracking is simple and cheap. Once the list can change shape, a key taken from the data is required for correctness.

saying these in an interview costs you the question

  • Angular re-sorts DOM nodes itself whenever the array order changes
  • The bug is change detection not running after the sort
  • Tracking by player name is as good as tracking by id
  • Switching to track p always fixes state following the row
  • A missing trackBy on a child component causes this