skip to content

When migrating an Angular *ngFor that uses a trackBy function to @for, what does the track clause become and what behaviour changes?

level: seniorimportance: should knowfreq 45%

answer

  1. NgForOf deprecated since v20
  2. trackBy(index, item) becomes a call
  3. prefer an inline field
  4. missing trackBy means identity
  5. reuse instead of remount

basics

~20 s

A trackBy: trackById option becomes track trackById($index, item), or better an inline track item.id; an *ngFor without trackBy maps to identity tracking, which should be replaced by an id. Afterwards @for reuses views in place where *ngFor sometimes remounted them.

solid answer

~50 s

`*ngFor` (the `NgForOf` directive, deprecated since v20) took an optional `trackBy` function with the signature `(index, item) => key`. In `@for` that becomes the required `track` clause: `trackBy: trackById` maps to `track trackById($index, item)`, and the cleaner result is to inline the key, `track item.id`, and delete the method. Loops that had no `trackBy` were tracked by object identity, so their literal translation is `track item`; review those, because over immutable data they rebuild every row. Locals change too: `index`, `first`, `last` and friends become `$index`, `$first`, `$last`, no `NgFor`/`CommonModule` import is needed, and an empty-state `*ngIf` becomes `@empty`. The behaviour change to test is view reuse: when a tracked property changes on the same object, `@for` updates the existing row's bindings, whereas `*ngFor` with a `trackBy` returning a new value destroyed and recreated it.

code

html · 10 lines
html
<!-- before: NgForOf, deprecated since v20 -->
<li *ngFor="let o of orders; trackBy: trackById; let i = index">{{ i + 1 }}. {{ o.ref }}</li>
<li *ngIf="orders.length === 0">No orders</li>

<!-- after: built-in control flow -->
@for (o of orders; track o.id; let i = $index) {
  <li>{{ i + 1 }}. {{ o.ref }}</li>
} @empty {
  <li>No orders</li>
}

go deeper

for a junior

Recall that *ngFor is deprecated since v20, that trackBy becomes the required track clause, and that index becomes $index.

for a middle

Map each trackBy form to a track expression and explain why a missing trackBy translates to identity tracking.

for a senior

Review migrated loops for identity and index keys, and find row components that depended on being remounted, fixing them with input-derived state.

for a principal

Plan the rollout: run the migration in slices, gate on a clean dev console and row-state tests, and remove NgFor imports as the last step.

## Where the two syntaxes stand - **`*ngFor`** is the structural-directive shorthand for `NgForOf` from `@angular/common`. It is **deprecated since v20**, with an intent to remove it in a future major, and still appears in most production codebases. - **`@for`** is the built-in control flow available since v17. It needs no import and requires a `track` expression. The Angular team ships an automated migration for templates (`ng generate @angular/core:control-flow`); what matters for the interview is what the result means and what to review after it. ## Mapping trackBy to track `NgForOf`'s `trackBy` takes a `TrackByFunction`, called with `(index, item)` and returning the key: ```ts trackById(index: number, order: Order): string { return order.id; } ``` | `*ngFor` form | Literal `@for` translation | Better hand-written form | |---|---|---| | `*ngFor="let o of orders; trackBy: trackById"` | `@for (o of orders; track trackById($index, o))` | `@for (o of orders; track o.id)` and delete `trackById` | | `*ngFor="let o of orders"` (no trackBy) | `@for (o of orders; track o)` | `@for (o of orders; track o.id)` | | `*ngFor="let t of tabs; trackBy: trackByIndex"` | `@for (t of tabs; track trackByIndex($index, t))` | `@for (t of tabs; track $index)`, only if the list is static | Points to check in each translated loop: 1. **Loops that had no `trackBy`.** `NgForOf` defaulted to object identity, so the faithful translation is `track item`. That preserves behaviour, but it is the option the Angular docs rank last, and over data that arrives as new objects it recreates every row (flagged in dev mode as `NG0956`). Replace it with an id wherever one exists. 2. **What the key may read.** A `track` expression may read only the item, `$index` and component members; it cannot use pipes. A `trackBy` method that closed over other state can stay a component method, called as `track fn($index, item)`. 3. **Uniqueness.** `@for` warns about duplicate keys (`NG0955`); a sloppy `trackBy` that returned a non-unique value will now show up in the console. ## Mapping the local variables | `*ngFor` | `@for` | |---|---| | `let i = index` / `index as i` | `$index`, or `let i = $index` | | `count`, `first`, `last`, `even`, `odd` | `$count`, `$first`, `$last`, `$even`, `$odd` | | `<ng-container *ngIf="items.length; else none">` wrapper | `@empty { ... }` | | `<ng-container *ngFor>` needed to combine with `*ngIf` | `@if` can sit directly inside `@for` | In a standalone component, remove `NgFor`, `NgForOf` or `CommonModule` from `imports` once no template needs them. ## The behaviour change to test The migration guide lists one breaking change for loops, **view reuse**: - With `@for`, if a property used in the `track` expression changes while the object reference stays the same (an in-place mutation), Angular updates that row's bindings, including component inputs, instead of destroying and recreating the element. - With `*ngFor`, a `trackBy` returning a different value for that item made Angular **remount** the row: a new component instance, `ngOnInit` again, fresh internal state. Code that relied on the remount, for example a row component that reads its input once in `ngOnInit` or resets a form when a new row appears, will now keep its old internal state. Fix it by deriving the state from a signal input with `computed()`, or by reacting to input changes explicitly. ## A review checklist for a migrated list - [ ] every `track o` came from a missing `trackBy`: replaced by an id where possible; - [ ] every `track fn($index, o)` checked: inline the field if `fn` only returns it; - [ ] no `track $index` on a list that can reorder, filter or delete; - [ ] row components do not depend on being recreated when their item changes; - [ ] empty-state markup moved into `@empty`; - [ ] unused `NgFor` / `CommonModule` imports removed; - [ ] console clean of `NG0955` and `NG0956` in a dev build.

  • Why does a literal migration of an Angular *ngFor without trackBy produce track item, and why review it?
    `NgForOf` tracked by object identity when no `trackBy` was given, so `track item` preserves the old behaviour exactly. It is still the weakest key: data that arrives as fresh objects makes `@for` destroy and recreate every row, which dev mode reports as `NG0956`. Swapping in a stable id usually fixes both performance and lost row state.
  • After moving to Angular's @for, a row component no longer resets its form when its item changes. Why?
    `@for` reuses the existing view when the key still matches, so the row component instance survives and `ngOnInit` does not run again; it only receives the new item through its input. Under `*ngFor` the row could be remounted. Derive the form's initial value from the input, for example with an effect on a signal input, rather than relying on re-creation.

saying these in an interview costs you the question

  • @for still accepts a trackBy: option like *ngFor
  • A *ngFor without trackBy should become track $index
  • *ngFor was removed in v20, so old templates no longer compile
  • @for and *ngFor recreate rows in exactly the same situations
  • You still need CommonModule imported for @for to work