Control Flow: @switch
When a template needs to pick exactly one of several mutually exclusive views based on a single value, chaining @if/@else if quickly becomes noisy. Angular’s @switch block, introduced as part of the built-in control flow in Angular 17, gives you a clean, declarative multi-branch construct that reads like a TypeScript switch statement. It works in any standalone component template with zero imports, and it integrates naturally with signals.
The basic shape
A @switch block evaluates a single expression once, then renders the first @case whose value matches. An optional @default block renders when no case matches. Only one branch is ever instantiated in the DOM.
@switch (status()) {
@case ('loading') {
<app-spinner />
}
@case ('error') {
<p class="error">Something went wrong. Please retry.</p>
}
@case ('ready') {
<app-dashboard [data]="data()" />
}
@default {
<p>Unknown state.</p>
}
}
The block is part of the template language itself, so there is nothing to import. This replaces the older structural-directive approach ([ngSwitch], *ngSwitchCase, *ngSwitchDefault) that required CommonModule.
Matching semantics
Matching uses strict equality (===) between the switch expression and each @case value. There is no fall-through: matching a case does not continue into the next one, so you never need break. If multiple cases share a value, only the first matching one renders.
import { Component, signal } from '@angular/core';
type Plan = 'free' | 'pro' | 'enterprise';
@Component({
selector: 'app-plan-badge',
standalone: true,
template: `
<button (click)="cycle()">Cycle plan</button>
@switch (plan()) {
@case ('free') {
<span class="badge">Free tier · limited features</span>
}
@case ('pro') {
<span class="badge badge--pro">Pro · {{ seats() }} seats</span>
}
@case ('enterprise') {
<span class="badge badge--ent">Enterprise · unlimited</span>
}
@default {
<span class="badge">Select a plan</span>
}
}
`,
})
export class PlanBadgeComponent {
protected readonly plan = signal<Plan>('free');
protected readonly seats = signal(5);
protected cycle(): void {
const order: Plan[] = ['free', 'pro', 'enterprise'];
const next = order[(order.indexOf(this.plan()) + 1) % order.length];
this.plan.set(next);
}
}
Because the switch expression is a signal call, the matched branch updates automatically whenever the signal changes. Clicking the button cycles through the plans and swaps the rendered badge.
Output:
[Free tier · limited features] (initial)
[Pro · 5 seats] (after one click)
[Enterprise · unlimited] (after two clicks)
Matching against constants and enums
@case values can be any expression, but they are most readable as literals or component members. Enums work well because they keep the magic strings out of the template.
import { Component, signal } from '@angular/core';
enum OrderStage {
Pending = 'PENDING',
Shipped = 'SHIPPED',
Delivered = 'DELIVERED',
}
@Component({
selector: 'app-order-status',
standalone: true,
template: `
@switch (stage()) {
@case (Stage.Pending) {
<p>Your order is being prepared.</p>
}
@case (Stage.Shipped) {
<p>On its way! Track your parcel.</p>
}
@case (Stage.Delivered) {
<p>Delivered. Enjoy!</p>
}
}
`,
})
export class OrderStatusComponent {
protected readonly stage = signal<OrderStage>(OrderStage.Pending);
// Expose the enum to the template
protected readonly Stage = OrderStage;
}
Templates cannot reference an
enumdirectly — they only see component class members. Assign the enum to a field (hereStage) so@case (Stage.Pending)resolves.
Note there is no @default above. When no case matches, nothing is rendered, which is perfectly valid.
@switch vs nested @if
For two outcomes, @if/@else is simpler. Reach for @switch when you branch on one value across three or more discrete states. The table below summarizes the trade-offs.
| Concern | @switch | Chained @if / @else if |
|---|---|---|
| Best for | One value, many discrete states | Boolean / heterogeneous conditions |
| Comparison | Strict === against each case | Any boolean expression |
| Default branch | @default | Trailing @else |
| Readability at 3+ branches | High | Degrades quickly |
| Fall-through | None (no break needed) | N/A |
Comparison with the legacy syntax
The new block-based control flow is the recommended approach and is what ng generate scaffolds. The legacy directive form still works but requires importing CommonModule.
| Feature | Built-in control flow | Legacy directives |
|---|---|---|
| Switch | @switch (expr) { ... } | [ngSwitch]="expr" |
| Case | @case (value) { ... } | *ngSwitchCase="value" |
| Default | @default { ... } | *ngSwitchDefault |
| Import needed | None | CommonModule |
You can migrate an entire project automatically:
ng generate @angular/core:control-flow
Best practices
- Prefer
@switchover a long@if/@else ifchain once you branch on a single value across three or more states. - Always include a
@defaultbranch when the switch value could fall outside the known cases — it makes “unhandled” states explicit instead of silently rendering nothing. - Drive the switch expression from a signal or computed so the matched branch reacts automatically to state changes.
- Expose enums and constants as component fields rather than hard-coding string literals in
@case, which keeps values type-checked and refactor-safe. - Keep each
@casebody small; if a branch grows large, extract it into a dedicated child component. - Run
ng generate @angular/core:control-flowto migrate legacy[ngSwitch]usage and drop theCommonModuledependency.