Use the at-if, at-for and at-switch blocks that replaced structural directives, and understand why track matters so much.

Bishal Biswas
Self Learner
Built into the language
Angular 17 added control flow blocks to the template syntax itself. They became stable in version 18 and are now the recommended way to write conditionals and loops.
@if (user()) {
<p>Welcome back</p>
} @else {
<p>Please sign in</p>
}
No imports. No asterisks. No ng-template. It is part of the template language, like {{ }}.
at-if, at-else-if and at-else
@if (status() === 'loading') {
<app-spinner />
} @else if (status() === 'error') {
<p class="error">Something went wrong</p>
} @else {
<app-results [items]="items()" />
}
The as clause captures the value, which narrows the type:
@if (user(); as u) {
<p>{{ u.name }}</p> <!-- u is User, not User | null -->
}
That narrowing is a genuine improvement over *ngIf, where you had to use *ngIf="user as u" and the compiler was less helpful.
at-for and the track expression
@for (user of users(); track user.id) {
<app-user-card [user]="user" />
} @empty {
<p>No users found</p>
}
Two things stand out.
track is required. The old *ngFor made trackBy optional and almost nobody used it. Making it mandatory was deliberate, because it is the single biggest performance lever in a list.
@empty handles the empty case without a separate @if.
Why track matters
track tells Angular how to identify an item across renders. With it, Angular moves existing DOM nodes. Without a good one, it destroys and recreates them.
<!-- good: a stable unique id -->
@for (user of users(); track user.id) { ... }
<!-- acceptable for a static list that never reorders -->
@for (item of items(); track $index) { ... }
<!-- bad: the object identity changes whenever data is refetched -->
@for (user of users(); track user) { ... }
The visible symptom of a bad track: an input inside the loop loses what you typed, or loses focus, whenever the list updates. Component state inside each row is destroyed and rebuilt.
Use $index only when the list never reorders and rows have no state. Otherwise use a stable id from the data.
The loop variables
@for (user of users(); track user.id; let i = $index, isFirst = $first) {
<p>{{ i + 1 }}. {{ user.name }} {{ isFirst ? '(top)' : '' }}</p>
}
Available inside the loop:
$index position, starting at 0
$first true for the first item
$last true for the last item
$even true when $index is even
$odd true when $index is odd
$count total number of items
They can be used directly without aliasing:
@for (user of users(); track user.id) {
<div [class.striped]="$even">{{ $index }}: {{ user.name }}</div>
}
Aliasing with let is needed when loops are nested and both $index values matter.
@for (row of rows(); track row.id; let rowIndex = $index) {
@for (cell of row.cells; track cell.id) {
<td>{{ rowIndex }},{{ $index }}</td>
}
}
at-switch
@switch (role()) {
@case ('admin') {
<app-admin-panel />
}
@case ('editor') {
<app-editor-tools />
}
@default {
<app-viewer />
}
}
Comparison is strict equality, the same as ===. There is no fall through, so no break is needed.
@default is optional. Without it, an unmatched value renders nothing.
Why this replaced structural directives
<!-- old -->
<div *ngIf="user; else guest">{{ user.name }}</div>
<ng-template #guest><p>Sign in</p></ng-template>
<!-- new -->
@if (user) {
<div>{{ user.name }}</div>
} @else {
<p>Sign in</p>
}
Four advantages, all real:
No imports. *ngIf needed NgIf from CommonModule in every component’s imports.
Better type narrowing. The compiler understands the blocks natively.
No ng-template gymnastics for else and switch branches.
Smaller bundles. The blocks compile to direct instructions rather than pulling in directive code.
There is also no way to accidentally put two structural directives on one element, which was a common *ngIf and *ngFor mistake.
Migrating
ng generate @angular/core:control-flow
That rewrites *ngIf, *ngFor and *ngSwitch throughout the project and removes the now unused imports. It handles most code correctly, so review the diff and move on.
Nesting and readability
Blocks nest freely, but deep nesting in a template is as unpleasant as it is in code.
@if (user(); as u) {
@if (u.orders.length > 0) {
@for (order of u.orders; track order.id) {
@if (order.status === 'open') {
<app-order [order]="order" />
}
}
}
}
Better: move the filtering into a computed signal and keep the template flat.
protected readonly openOrders = computed(
() => this.user()?.orders.filter(o => o.status === 'open') ?? []
);
@for (order of openOrders(); track order.id) {
<app-order [order]="order" />
} @empty {
<p>Nothing open</p>
}
Common mistakes
Forgetting track. It is a compile error, which is the point.
Tracking a mutating object. track user breaks when data is refetched, since the objects are new.
Calling a method in the loop expression.
@for (user of getUsers(); track user.id) { ... } <!-- runs on every check -->
@for (user of users(); track user.id) { ... } <!-- a signal, cheap -->
Using @if where @switch is clearer. Three or more branches on the same value read better as a switch.
Quick recap
@if,@forand@switchare part of the template language and need no imports.@if (x; as y)narrows the type inside the block.trackis mandatory in@for, and a stable id is the right choice.- A bad
trackdestroys DOM and loses focus and input state. @emptyhandles empty lists, and$index,$firstand$lastare always available.@switchuses strict equality with no fall through.ng generate @angular/core:control-flowmigrates an existing project.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-control-flow',
// NO imports needed. Control flow is part of the template language.
template: `
<!-- as narrows the type: u is User, not User | null -->
@if (user(); as u) {
<p>Welcome, {{ u.name }}</p>
} @else if (loading()) {
<p>Loading...</p>
} @else {
<p>Please sign in</p>
}
<!-- track is MANDATORY. Use a stable id, not the object. -->
@for (order of openOrders(); track order.id) {
<div [class.striped]="$even">{{ $index + 1 }}. {{ order.ref }}</div>
} @empty {
<p>Nothing open</p>
}
<!-- nested loops: alias the outer index -->
@for (row of rows(); track row.id; let rowIndex = $index) {
@for (cell of row.cells; track cell.id) {
<td>{{ rowIndex }},{{ $index }}</td>
}
}
<!-- strict equality, no fall through, no break -->
@switch (role()) {
@case ('admin') { <app-admin-panel /> }
@case ('editor') { <app-editor-tools /> }
@default { <app-viewer /> }
}
`,
})
export class ControlFlow {
protected readonly user = signal<{ name: string; orders: Order[] } | null>(null);
protected readonly loading = signal(false);
protected readonly role = signal<'admin' | 'editor' | 'viewer'>('viewer');
protected readonly rows = signal<{ id: number; cells: { id: number }[] }[]>([]);
// Filter in a computed signal, not in the template. Keeps the template flat.
protected readonly openOrders = computed(
() => this.user()?.orders.filter(o => o.status === 'open') ?? []);
}
interface Order { id: number; ref: string; status: string; }
// Loop variables, always available: $index $first $last $even $odd $count
// Bad track = DOM destroyed and rebuilt = inputs lose focus and typed text.
// Migrate an old codebase with: ng generate @angular/core:control-flow
Previous
Event Binding and Template Statements
Next
Legacy Structural Directives: ngIf, ngFor and ngSwitch
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready