Template control flow, pipes and deferred views
Use the built-in control flow blocks, keep pipes pure, and split a heavy page with @defer so the first paint is not waiting on a chart library.
@if, @for, @switch and @let
@if (user(); as currentUser) {
<h1>Welcome back, {{ currentUser.name }}</h1>
} @else if (loading()) {
<p aria-live="polite">Loading profile…</p>
} @else {
<a routerLink="/login">Sign in</a>
}
@for (invoice of invoices(); track invoice.id) {
<li>
<span>{{ invoice.id }}</span>
<strong>{{ invoice.total }}</strong>
</li>
} @empty {
<li>No invoices yet.</li>
}
@switch (status()) {
@case ('draft') { <span class="badge">Draft</span> }
@case ('sent') { <span class="badge">Sent</span> }
@default { <span class="badge">Unknown</span> }
}
@let subtotal = items().reduce((sum, i) => sum + i.price, 0);
@let tax = subtotal * taxRate();
<p>Total: {{ subtotal + tax }}</p>trackis required on@forwhen you iterate objects. Tracking by a stable id lets Angular move DOM nodes rather than rebuild them; tracking by index defeats the purpose entirely.@emptyreplaces the*ngIfpluslength === 0workaround and handles the empty collection inline.- @let declares a template-local value, computed once per change-detection pass for that view — the right place for a repeated calculation in a template.
- @if narrows types, so
user()is non-null inside the block. That is a large part of why the new syntax exists.
⚠️
Tracking by index is the most common performance mistake with
@for: when an item is removed, every row after it is considered changed and is re-rendered. Track by a real identifier, and if the list has none, create one when the data arrives.Pipes and their gotchas
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'timeAgo',
standalone: true,
pure: true // default: re-runs only when the input reference changes
})
export class TimeAgoPipe implements PipeTransform {
private readonly now = signal(Date.now());
transform(value: Date | string | number, suffix = 'ago'): string {
const ms = Date.now() - new Date(value).getTime();
const days = Math.floor(ms / 86_400_000);
if (days < 1) return 'today';
return `${days} day${days === 1 ? '' : 's'} ${suffix}`;
}
}<p>{{ invoice.createdAt | date: 'mediumDate' }}</p>
<p>{{ total | currency: 'EUR' : 'symbol' : '1.2-2' }}</p>
<p>{{ description | slice: 0 : 120 }}{{ (description | slice: 120).length ? '…' : '' }}</p>
<p>{{ invoice.createdAt | timeAgo }}</p>
<!-- async pipe: one subscription, unsubscribed automatically on destroy -->
@if (invoices$ | async; as invoices) {
<p>{{ invoices.length }} invoices</p>
}| Pipe | Behaviour | Watch out |
|---|---|---|
date | Formats a date | Needs a locale provider for non-English output |
currency | Formats money | Pass the currency code explicitly rather than relying on the default |
slice | Slices an array or string | On a string, indexing is character-based, not grapheme-based |
async | Subscribes and unwraps | Each use is a separate subscription — pipe once, bind many times |
json | Debug output | Remove before shipping; it is a serialisation cost on every change detection pass |
DecimalPipe | Numeric formatting | The digits-info string is easy to get wrong; test it |
// An impure pipe runs on every change detection pass — usually a bug.
@Pipe({ name: 'filter', standalone: true, pure: false })
export class FilterPipe implements PipeTransform {
transform(items: Item[], term: string) {
return items.filter((i) => i.name.toLowerCase().includes(term.toLowerCase()));
}
}
// Prefer a computed instead: it caches and it is obviously a data concern.
// readonly visible = computed(() =>
// this.items().filter((i) => i.name.toLowerCase().includes(this.term().toLowerCase())));Deferred views with @defer
@defer (on viewport; prefetch on idle) {
<app-revenue-chart [data]="series()" />
} @placeholder (minimum 200ms) {
<div class="chart-skeleton" aria-hidden="true"></div>
} @loading (minimum 300ms) {
<p role="status">Loading chart…</p>
} @error {
<p role="alert">The chart could not be loaded.</p>
}
@defer (on interaction(loadButton)) {
<app-report-builder />
}
@defer (when expanded()) {
<app-audit-log [entries]="entries()" />
}| Trigger | Fires when | Typical use |
|---|---|---|
on idle | The browser is idle | Below-the-fold content that is almost always wanted |
on viewport | The placeholder enters the viewport | Long pages with heavy sections |
on interaction | A click, keypress or focus on a target | Tabs, dialogs, expandable panels |
on hover | The pointer hovers the placeholder | Menus with expensive content |
on timer(5s) | After a delay | Non-critical widgets |
when expr | An expression becomes truthy | Explicit control you already have as state |
@placeholderis what the user sees before the trigger, so give it the real size of the content or the page will visibly jump.minimumon@loadingstops a flash of the loading state when the chunk arrives quickly.prefetch on idledownloads the chunk early while still deferring the render, which is usually the best of both.- @defer works only on components that are not referenced elsewhere in the same file; the compiler must be able to isolate the dependency.
- Deferring an eagerly imported component achieves nothing — the code is already in the initial bundle.
// The deferred component must be a real, separately-loadable boundary.
@Component({
selector: 'app-revenue-chart',
standalone: true,
imports: [BaseChartDirective], // keeps the chart library out of the initial bundle
template: `<canvas baseChart [data]="data()"></canvas>`
})
export class RevenueChartComponent {
readonly data = input.required<ChartData>();
}FAQ
Why does @for complain about a missing track?
Without a track expression Angular cannot know which DOM node corresponds to which item, so it cannot move nodes on reorder and must rebuild. Tracking by a stable id is required for objects and strongly recommended for primitives.
Does @defer reduce the initial bundle?
Only if the deferred component and its dependencies are not imported anywhere else eagerly. If the same component is referenced in another template that loads immediately, the bundler keeps it in the initial chunk and the defer block adds nothing but complexity.
Related
Signals, computed values and effects Change detection, zoneless and performance
Last refreshed 2026-09-18.