Angular PrimerAngular 22 · 2022 → 2026

Templates & control flow

`@if`, `@for` and `@switch` are syntax now, not directives shipped in a shared module. Plus `@let`, `@defer`, and the v22 additions to the expression syntax.

@if and @else

The structural directives still work, but the block syntax is the default and reads like the code it is:

2022 — structural directive and ng-template

<div *ngIf="user$ | async as user; else loading"> <p>Hello, {{ user.name }}</p> </div> <ng-template #loading><p>Loading…</p></ng-template>

2026 — @if / @else if / @else

@if (user(); as user) { <p>Hello, {{ user.name }}</p> } @else if (failed()) { <p>Could not load the user.</p> } @else { <p>Loading…</p> }

Two practical wins: no NgIf import (so no CommonModule), and as aliases the value, not the expression, which means the alias is typed and narrowed.

@for, and the required track

@for is where the change is most visible, because the old default was the wrong one. *ngFor without trackBy re-rendered the whole list whenever the array identity changed:

2022 — ngFor, with trackBy when you remembered

<li *ngFor="let item of items; trackBy: trackById"> {{ item.name }} </li> trackById(_index: number, item: Item) { return item.id; }

2026 — @for, track is mandatory

@for (item of items(); track item.id) { <li>{{ item.name }}</li> } @empty { <li>Nothing here yet.</li> }

track is a required part of the syntax — the compiler will not let you forget it. @empty removes the *ngIf="items.length" wrapper you always ended up writing. Inside the block you get the loop variables for free: $index, $first, $last, $even, $odd, and $count.

2026 — loop variables
@for (row of rows(); track row.id; let i = $index, last = $last) {
  <tr [class.last]="last"><td>{{ i + 1 }}</td><td>{{ row.label }}</td></tr>
}

Track by identity, not by index. track $index is legal and is exactly the behaviour that made *ngFor slow: insert an item at the top of the list and every row is treated as changed. If your items genuinely have no stable identity, give them one before they reach the template.

@for with track, and @empty

@for requires a track expression — here it is the item id, so removing the first row re-uses the DOM for the rest. Clear the list to see the @empty block take over.

  • signal
  • computed
  • effect

@switch

@switch replaces [ngSwitch] with two *ngSwitchCase directives and a container:

2026 — @switch with multi-value cases
@switch (state()) {
  @case ('idle') { <p>Ready.</p> }
  @case ('loading') { <p>Working…</p> }
  @case ('error') { <p>Something failed.</p> }
  @default { <p>Done.</p> }
}

v22 adds two things worth knowing. A single @case can list several values — @case ('error') and @case ('failed') can be one branch — and @switch can be checked for exhaustiveness: switch on a union-typed signal, leave out a member, and if the value type is never in the default branch the compiler tells you a case is missing.

@switch, multi-value cases, @default

Each status renders its own branch, two of them share a single branch, and a status with no case falls through to @default.

status() = idle

Ready when you are.

@let

@let declares a local variable inside the template, so an expensive or deeply nested expression can be read once and given a name:

2026 — @let
@let total = order().items.length;
@let expensive = summary();

<p>{{ total }} items, {{ expensive.label }}</p>

It is not a signal and it is not reactive in itself — it is re-evaluated when the view is checked, like any other template expression. Reach for it to name a value, not to cache a computation (that is what computed is for).

@let: naming a value in the template

The count and its label are declared once with @let and read twice, including the plural rule — no method call, no extra pipe.

count = 2 · label = 2 items

the same local, read a second time: 2 items

@defer: lazy-loading part of a page

@defer splits a chunk out of the page and loads it when a trigger fires. This is the feature that made "lazy loading" a template-level decision rather than a routing-level one:

2026 — @defer on viewport, with all three states
@defer (on viewport) {
  <app-heavy-chart [data]="data()" />
} @placeholder (minimum 200ms) {
  <div class="chart-skeleton"></div>
} @loading (after 100ms; minimum 1s) {
  <p>Loading the chart…</p>
} @error {
  <p>The chart could not be loaded.</p>
}

The triggers are on idle, on viewport, on interaction, on hover, on timer(5s), on immediate, and when condition — and they compose with prefetch. @placeholder is what renders before the trigger fires, @loading while the chunk arrives, @error if it fails. Both @placeholder and @loading accept minimum (show it for at least this long, to avoid a flash) and @loading accepts after (wait this long before showing anything).

@defer: the chart arrives on view

The placeholder is what renders first. Scroll the block into view and the chart chunk is fetched — @loading covers the wait, and minimum stops the placeholder from flashing.

Nothing has loaded yet — this is the placeholder. It is a few bytes of HTML, and the chart component is not in the bundle until the block scrolls into view.

Pipes are unchanged, mostly

Pipes are still pipes, and since v15 a pipe can be standalone by default, so a component imports the one pipe it uses instead of CommonModule. A pure pipe re-runs only when its inputs change; an impure pipe (pure: false) runs on every check and is usually a sign that the work belongs in a computed.

The v22 expression additions

Four small syntax additions that remove template gymnastics:

2026 — spread, rest, inline arrows, multi-value case
<!-- spread into a component input -->
<app-form [config]="{ ...defaultConfig, ...overrides() }" />

<!-- rest of an object into a local -->
@let { title, ...rest } = page();

<!-- an inline arrow function as a callback -->
<app-list [filter]="(item) => item.active" />

<!-- one branch for several values -->
@switch (status()) {
  @case ('failed') @case ('error') { <p>It broke.</p> }
  @default { <p>Fine.</p> }
}

The arrow functions are inferred, so you do not annotate the parameter, and they capture the template's context — which is what makes them useful for passing a predicate into a child component without a method on the class.

What to take away

Templates stopped being a separate language with its own directives for the basics: control flow is syntax, loops must declare their identity, and a piece of the page can be deferred without a route. The same shift is visible in The component contract, where inputs and queries became signals, and in Performance, where @defer and track are the two levers that matter most.