05 · Template Control Flow¶
Templates need to show things conditionally, repeat things for each item in a list, and
pick one of several branches. Angular's template syntax has built-in blocks for this,
written with an @: @if, @for, @switch, plus @let for local variables. They are
part of the template language itself, so there is nothing to import.
You will still see the older structural directives — *ngIf, *ngFor, [ngSwitch] — in
existing code. They were deprecated in Angular 20 in favour of the blocks, and
ng generate @angular/core:control-flow migrates them automatically (Level 4, lesson 05).
A worked example¶
import { Component, computed, signal } from '@angular/core';
type Status = 'pending' | 'shipped' | 'delivered' | 'cancelled';
interface Order { id: number; customer: string; total: number; status: Status; items: string[] }
@Component({
selector: 'app-order-view',
template: `
@let open = openOrders();
<h2>Orders ({{ open.length }} open)</h2>
@if (loading()) {
<p>Loading…</p>
} @else if (orders().length === 0) {
<p>No orders yet.</p>
} @else {
<ol>
@for (order of orders(); track order.id; let i = $index, last = $last) {
<li [class.last]="last">
#{{ i + 1 }} {{ order.customer }} — {{ order.total }}
@switch (order.status) {
@case ('pending') { <em>awaiting payment</em> }
@case ('shipped') { <strong>on its way</strong> }
@case ('delivered') { <span>delivered</span> }
@case ('cancelled') { <del>cancelled</del> }
@default never;
}
@if (order.items[0]; as first) { (first item: {{ first }}) }
</li>
} @empty {
<li>Nothing to show.</li>
}
</ol>
}
`,
})
export class OrderView {
protected readonly loading = signal(false);
protected readonly orders = signal<Order[]>([
{ id: 7, customer: 'Ada', total: 42, status: 'shipped', items: ['Keyboard'] },
{ id: 9, customer: 'Linus', total: 13, status: 'pending', items: [] },
]);
protected readonly openOrders = computed(() =>
this.orders().filter((o) => o.status === 'pending' || o.status === 'shipped'),
);
}
@if¶
The as form stores the condition's value in a local variable, which is handy when the
condition is an expression you don't want to repeat and TypeScript narrowing matters:
The template type checker narrows types inside the block just like TypeScript does after
an if, so u.name is not flagged as possibly null.
@for and track¶
- The iterable can be any array or iterable (including a
Map's entries or aSet). trackis required. Leave it out and the build stops:NG5002: @for loop must have a "track" expression.@emptyrenders when the collection has no items.
Inside the loop you can read these implicit variables, aliasing them with let when you
want a shorter name: $index, $count, $first, $last, $even, $odd.
Choosing a track expression¶
track tells Angular how to recognise the same item between renders. Use a stable,
unique identity — usually a database ID. Use track $index only for static lists of
primitives that never reorder. Use track item (the object itself) only when you replace
the whole object on every change and don't mind all rows being recreated when the data is
refetched.
The difference is not cosmetic. We rendered the same three people twice, once tracked by
p.id and once by $index, each row containing an unbound <input>. After typing into
Ada's input in both lists and reversing the array, Chromium showed:
#by-id ['Linus | input=""', 'Grace | input=""', 'Ada | input="typed next to Ada"']
#by-index ['Linus | input="typed next to Ada"', 'Grace | input=""', 'Ada | input=""']
With track p.id, Angular moved Ada's <li> (and the input inside it) to the end.
With track $index, row 0 is "the same row" before and after, so Angular kept the DOM
node in place and only changed its text to "Linus" — and the typed text stayed behind
with the wrong person. The same thing happens to focus, scroll position, animations and
child component state.
Duplicate track keys are a bug, and development builds warn about them in the console:
NG0955: The provided track expression resulted in duplicated keys for a given collection.
Adjust the tracking expression such that it uniquely identifies all the items in the
collection. Duplicated keys were: key "1" at index "0" and "1".
@switch¶
Cases compare with ===. There is no fall-through, so no break.
When the switched value is a union type and you want the compiler to make sure every
member is handled, end with @default never;. Delete the cancelled case from the
example and the build fails:
✘ [ERROR] TS2322: Type '"cancelled"' is not assignable to type 'never'.
21 │ @switch (order.status) {
That turns "someone added a new status and forgot the UI" into a compile error.
Exhaustiveness relies on TypeScript narrowing the switched expression, and a
function call can't be narrowed. Switching directly on a signal — @switch (status())
with @default never; — fails even when every case is covered:
✘ [ERROR] TS2322: Type 'IssueStatus' is not assignable to type 'never'.
Type '"open"' is not assignable to type 'never'.
Read the signal into a @let first and switch on that:
@let s = status();
@switch (s) {
@case ('open') { Open }
@case ('in-progress') { In progress }
@case ('closed') { Closed }
@default never;
}
@let¶
@let name = expression; declares a read-only template variable. It is re-evaluated on
every refresh of the view, so it stays in sync with the signals it reads. Use it to avoid
repeating a long expression or calling a signal many times:
@let variables are visible to the rest of the template after the declaration, including
inside nested blocks, but not before it. They are not a place for logic that belongs in a
computed.
How It Actually Works¶
Each block compiles to an embedded view — a small, separately created chunk of DOM with its own bindings — plus instructions that decide which embedded views exist.
@if/@switchcompile to a conditional instruction. On each update the condition is evaluated; if the selected branch index changed, the old embedded view is destroyed and the new one created. If it didn't change, the existing view is simply refreshed. That's why state inside an@if(an input's text, a child component) is lost when the condition flips off and on — the DOM is really removed.@forcompiles to a repeater. On each update Angular computes the track key for every item and runs a reconciliation algorithm against the keys from the previous render: keys that disappeared have their views destroyed, new keys get new views, and keys that moved have their existing views moved in the DOM, not recreated. The algorithm is optimised for the common cases — appending, removing one, swapping two — so it avoids scanning the whole list when it can. Items whose key is unchanged but whose object is a new reference keep their view; only the bindings inside are updated.
This is also why track became mandatory with the new syntax. The old *ngFor defaulted
to object identity, which silently recreated every row whenever an HTTP call returned
fresh objects — one of the most common Angular performance problems. Requiring an
explicit key makes the choice visible.
Common mistakes¶
- Tracking by
$indexin a list that can reorder, insert or delete. State ends up attached to the wrong row, as shown above. - Tracking by a property that isn't unique (a name, a date). Expect NG0955 warnings and odd row reuse.
- Heavy expressions in the iterable.
@for (x of filterAndSort(items()); ...)runs on every refresh; put it in acomputed. - Mixing old and new syntax on the same element.
*ngIfstill works (if you importNgIf), but mixing styles makes templates hard to read; migrate a file at a time. - Expecting
@ifto hide rather than remove. Use[hidden]or a class if you need to keep the DOM (and its state) alive while invisible.
Exercise¶
Build a TaskBoard component with a tasks signal of
{ id: number; title: string; priority: 'low' | 'medium' | 'high'; done: boolean }.
- Show "All done 🎉" with
@ifwhen every task is done, otherwise the list. - Render tasks with
@for ... track task.id, prefixing each with its 1-based position and striking through done tasks. Show "No tasks" with@empty. - Render a badge per priority with
@switchand@default never;. Then add'urgent'to the union type and watch the build fail until you add a case. - Use
@letto compute the number of remaining tasks once and show it in two places. - Add a "Shuffle" button, put an
<input>in each row, and comparetrack task.idwithtrack $indexyourself.