When the new control flow arrived, *ngFor became @for and something subtle happened: trackBy, optional for a decade and ignored roughly that long, became track — mandatory. The compiler now refuses your loop without it. And I'd bet money on what most of us did: typed whatever made the error disappear and moved on.
Worth ten seconds of actual thought, because track is the answer to one specific question the renderer asks on every update: "is this the same row as before?"
Same row, says who?
When the array changes, Angular diffs old against new. Rows judged "the same" keep their DOM node, their component instances, their focus state, their CSS transitions. Rows judged "different" are destroyed and rebuilt from scratch. track is the judge — it maps each item to a key, and matching keys mean reuse.
@for (user of users(); track user.id) {
<app-user-row [user]="user" />
}
track user.id says: identity lives in the data. Reorder the array, refresh it from the server, replace every object with a clone — as long as the ids match, the DOM moves instead of dying. This is the right answer for anything that came out of a database, which is to say, most lists.
When $index is fine, and when it lies
track $index says: identity lives in the position. Row 3 is row 3, whatever it contains. That's correct in exactly two situations — the list is static (a hardcoded menu, weekdays), or the items are primitives with possible duplicates (a list of tags where two can be "angular", and ids don't exist).
Everywhere else it lies, in one of two directions. Insert an item at the top of a 200-row list: every position shifts, so the differ sees 200 "changed" rows and rebuilds them all — there goes your frame budget, and the work scales with list size. Worse is the quiet version: rows with internal state. An expanded accordion panel at index 4 stays expanded at index 4 after you sort the list — the state glued itself to the position while the data moved. Users report that one as "the app shuffled my stuff," and they're right.
The object-identity trap
track user — tracking the object itself — looks reasonable and breaks the moment your state management does the idiomatic thing. Immutable updates mean every refresh produces new object references for the same logical entities. New references, new identities, full rebuild — your "optimization" turned every server poll into a teardown of the entire list. If you've ever watched a list flicker on each refresh and couldn't say why, check what track is keyed on.
NG0955 is telling you something true
The duplicate-key error isn't the framework being pedantic. Two rows with the same key means the differ literally cannot tell them apart — reuse becomes arbitrary. When I hit NG0955 it has almost always flagged a real data bug upstream: an id that wasn't unique after a merge, a join that duplicated rows. Fix the data, not the track expression. track $index-as-silencer just moves the confusion into the DOM.
The ten-second rule
Has a stable id → track item.id. Static or primitive with duplicates → track $index. Neither → derive a real key (compose fields if you must) rather than lying about identity. That covers every list I've reviewed since the syntax landed — and unlike the trackBy era, the compiler now makes sure you at least showed up to the decision.
Top comments (0)