Built-in Control Flow in Angular
Angular 17 introduced built-in control flow — a new template syntax using @if, @for, and @switch blocks. These replace the older *ngIf, *ngFor, and *ngSwitch structural directives with a cleaner, more ergonomic syntax that requires no imports and offers better TypeScript type narrowing.
The new syntax was stabilised in Angular 17 and is now the recommended approach for all new code.
@if — Conditional Rendering
@if conditionally renders content based on an expression. It supports @else if and @else branches in a familiar programming-language style.
@Component({
selector: 'app-auth-status',
standalone: true,
template: `
@if (user) {
<p>Welcome, {{ user.name }}!</p>
<button (click)="logout()">Logout</button>
} @else if (isLoading) {
<p>Checking authentication...</p>
} @else {
<p>You are not logged in.</p>
<button (click)="login()">Login</button>
}
`,
})
export class AuthStatusComponent {
user: { name: string } | null = { name: 'Alice' };
isLoading = false;
login() { this.user = { name: 'Alice' }; }
logout() { this.user = null; }
}
@if with Type Narrowing
One of the biggest improvements over *ngIf is that @if narrows the type inside the block, enabling type-safe template expressions.
interface Admin { name: string; permissions: string[] }
interface Viewer { name: string; readOnly: true }
type UserRole = Admin | Viewer;
@Component({
selector: 'app-role-panel',
standalone: true,
template: `
@if (isAdmin(user)) {
<!-- TypeScript knows user is Admin here -->
<ul>
@for (perm of user.permissions; track perm) {
<li>{{ perm }}</li>
}
</ul>
} @else {
<!-- TypeScript knows user is Viewer here -->
<p>Read-only access</p>
}
`,
})
export class RolePanelComponent {
user: UserRole = { name: 'Bob', permissions: ['read', 'write'] };
isAdmin(u: UserRole): u is Admin {
return 'permissions' in u;
}
}
@for — List Rendering
@for iterates over an iterable and renders the block for each item. The track expression is required — it tells Angular how to identify items for efficient DOM updates.
@Component({
selector: 'app-task-list',
standalone: true,
template: `
<ul>
@for (task of tasks; track task.id) {
<li [class.done]="task.done">
<input type="checkbox" [checked]="task.done" (change)="toggle(task)" />
{{ task.title }}
</li>
}
</ul>
`,
})
export class TaskListComponent {
tasks = [
{ id: 1, title: 'Learn @for syntax', done: true },
{ id: 2, title: 'Build a component', done: false },
{ id: 3, title: 'Write tests', done: false },
];
toggle(task: { id: number; title: string; done: boolean }) {
task.done = !task.done;
}
}
@for Local Variables
@for exposes context variables using the let keyword. These give you index, count, and position information.
@for (item of items; track item.id; let i = $index, let c = $count, let f = $first, let l = $last, let e = $even, let o = $odd) {
<div [class.striped]="e" [class.first]="f">
{{ i + 1 }} / {{ c }}: {{ item.name }}
@if (l) { (last item) }
</div>
}
Variable | Type | Description |
|---|---|---|
$index | number | Zero-based position in the collection |
$count | number | Total number of items |
$first | boolean | True for the first item |
$last | boolean | True for the last item |
$even | boolean | True for even-indexed items (0, 2, 4...) |
$odd | boolean | True for odd-indexed items (1, 3, 5...) |
@for @empty — Handling Empty Collections
The @empty block is a first-class citizen in @for. It renders when the collection is empty or null/undefined.
@Component({
selector: 'app-search-results',
standalone: true,
template: `
<div class="results">
@for (result of results; track result.id) {
<div class="result-card">
<h3>{{ result.title }}</h3>
<p>{{ result.description }}</p>
</div>
} @empty {
<div class="empty-state">
<p>No results found for "{{ query }}"</p>
<button (click)="clearSearch()">Clear search</button>
</div>
}
</div>
`,
})
export class SearchResultsComponent {
query = 'Angular';
results: { id: number; title: string; description: string }[] = [];
clearSearch() {
this.query = '';
}
}
@switch — Multi-Branch Rendering
@switch evaluates an expression and renders the matching @case block. Use @default as a catch-all fallback.
@Component({
selector: 'app-user-role',
standalone: true,
template: `
@switch (role) {
@case ('admin') {
<div class="panel admin-panel">
<h2>Admin Dashboard</h2>
<p>Full system access</p>
</div>
}
@case ('editor') {
<div class="panel editor-panel">
<h2>Editor Workspace</h2>
<p>Create and edit content</p>
</div>
}
@case ('viewer') {
<div class="panel viewer-panel">
<h2>Read-Only View</h2>
<p>Browse published content</p>
</div>
}
@default {
<div class="panel error-panel">
<p>Unknown role: {{ role }}</p>
</div>
}
}
<select [(ngModel)]="role">
<option>admin</option>
<option>editor</option>
<option>viewer</option>
</select>
`,
})
export class UserRoleComponent {
role = 'admin';
}
Nesting Control Flow
Control flow blocks can be nested freely. Use this to build complex conditional list rendering.
@if (isLoading) {
<app-spinner />
} @else if (error) {
<app-error [message]="error" />
} @else {
<section>
@for (category of categories; track category.id) {
<div class="category">
<h2>{{ category.name }}</h2>
@if (category.products.length > 0) {
<ul>
@for (product of category.products; track product.id; let i = $index) {
<li>
{{ i + 1 }}. {{ product.name }}
@switch (product.status) {
@case ('new') { <span class="badge new">New</span> }
@case ('sale') { <span class="badge sale">Sale</span> }
}
</li>
}
</ul>
} @else {
<p>No products in this category yet.</p>
}
</div>
}
</section>
}
Migration from Structural Directives
Angular provides an official migration schematic to automatically convert your *ngIf/*ngFor/*ngSwitch usages to the new syntax.
# Migrate the entire project ng generate @angular/core:control-flow # Migrate a specific directory ng generate @angular/core:control-flow --path src/app/features
UPDATE src/app/app.component.html (120 bytes) UPDATE src/app/features/product-list/product-list.component.html (340 bytes) UPDATE src/app/features/cart/cart.component.html (215 bytes)
Old vs New Comparison
Old Directive | New Block | Key Difference |
|---|---|---|
*ngIf="x" | @if (x) { } | No imports, @else if built-in, type narrowing |
*ngIf="x; else tmpl" | @if (x) { } @else { } | Inline @else, no ng-template reference |
*ngFor="let i of items; trackBy: fn" | @for (i of items; track i.id) { } | track is required, @empty block built-in |
[ngSwitch] + *ngSwitchCase | @switch (x) { @case (v) { } } | Single block syntax, @default keyword |
Summary
@if / @else if / @else replaces *ngIf with automatic type narrowing and no imports needed.
@for (item of list; track item.id) replaces *ngFor — track is now required.
@empty inside @for handles empty collections without a separate *ngIf.
$index, $count, $first, $last, $even, $odd are available as let variables.
@switch / @case / @default replaces [ngSwitch] / *ngSwitchCase.
Control flow blocks can be freely nested.
Use the Angular CLI schematic ng generate @angular/core:control-flow to auto-migrate.