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
2026 — @if / @else if / @else
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
2026 — @for, track is mandatory
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.
@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:
@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:
@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:
@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.
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:
<!-- 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.