The @if, @for and @switch blocks are the native control syntax of templates (from v17, stable from v18). They replace the structural directives *ngIf, *ngFor and ngSwitch: they don’t need importing, have better typing, are faster and easier to read.
@if: conditions, else if, alias
@if (user(); as u) {
<p>Hello, {{ u.name }}!</p>
} @else if (loading()) {
<app-spinner />
} @else {
<a routerLink="/login">Log in</a>
}
<!-- `as` creates a typed (non-null) alias for the current block -->
@if (order()?.items?.length; as count) {
<span>{{ count }} items</span>
}@for: lists with a mandatory track
track is mandatory. It tells Angular how to recognise an element between two renders, so it reuses the DOM instead of recreating it. The @empty block appears when the list is empty.
<ul>
@for (task of tasks(); track task.id; let i = $index, last = $last) {
<li [class.last]="last">{{ i + 1 }}. {{ task.title }}</li>
} @empty {
<li>You have no tasks.</li>
}
</ul>
<!-- Variables available in @for:
$index $count $first $last $even $odd -->- 1/3 · Migrate *ngIf to @if$first
- 2/3 · Add track to every @for
- 3/3 · Remove CommonModule$last
How you choose the track expression
// ✓ Stable, unique id — the right choice in 95% of cases
@for (user of users(); track user.id) { ... }
// ✓ Unique primitive values (strings, numbers)
@for (tag of tags(); track tag) { ... }
// $index — only for static lists that are never reordered / filtered
@for (step of wizardSteps; track $index) { ... }
// ✗ The whole object (identity) when the list is recreated immutably or comes from the server:
// every refetch = new objects = the WHOLE DOM is destroyed and recreated
@for (user of users(); track user) { ... }1. Write a note next to Ana. 2. Press “Move the first to the end”. Where did the note end up?
A full catalog with search, sorting and pages, tracked by id, is on Lists. The same $index bug is one of the four on Your first app.
@switch, multiple cases and exhaustive checkingv21
@switch (user().role) {
@case ('admin') { <app-admin-dashboard /> }
@case ('editor') { <app-editor-dashboard /> }
@default { <app-viewer-dashboard /> }
}From v21.1 you can group several consecutive @case blocks over the same content, and from v21.2 @default never; turns a forgotten case into a compile error.
<!-- v21.1: several consecutive @case blocks for the same content -->
<!-- v21.2: @default never; → COMPILE error if you forget a case -->
@let current = status();
@switch (current) {
@case ('draft')
@case ('pending') { <span>Pending</span> }
@case ('paid') { <span>Paid</span> }
@case ('shipped')
@case ('delivered') { <span>Shipped</span> }
@case ('cancelled') { <span>Cancelled</span> }
@default never;
}Old syntax and migration
<!-- ✗ Old syntax (still works, but it is stylistically deprecated) -->
<div *ngIf="user$ | async as user; else loading">{{ user.name }}</div>
<ng-template #loading><app-spinner /></ng-template>
<li *ngFor="let t of tasks; trackBy: trackById; let i = index">{{ t.title }}</li>
<div [ngSwitch]="role">
<app-admin *ngSwitchCase="'admin'" />
<app-viewer *ngSwitchDefault />
</div>Run ng generate @angular/core:control-flow — the migration is automatic and safe. Afterwards you can usually remove CommonModule / NgIf / NgFor from imports.
Common pitfalls
track $indexon dynamic lists: the DOM state (inputs, focus, animations) stays at the position, not on the element.track itemon immutable data: every update creates new objects → the whole DOM is recreated.- Expensive calls in conditions:
@if (computeSomething())runs on every change detection. Move the logic into acomputed(). @switch (status())+@default never: the type doesn’t narrow on a signal call; use@let s = status();and@switch (s).- Special characters: in a template, a literal
@and{ }must be written as entities (@,{).
Cheat sheet
@if (cond) { } @else if (other) { } @else { }
@if (expr; as alias) { }
@for (item of items; track item.id; let i = $index, f = $first) { } @empty { }
// $index $count $first $last $even $odd
@switch (value) {
@case (a) @case (b) { } // v21.1: multiple cases
@default { } // or: @default never; (v21.2, exhaustive)
}
// migration: ng generate @angular/core:control-flow