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>
  • track is required on @for when you iterate objects. Tracking by a stable id lets Angular move DOM nodes rather than rebuild them; tracking by index defeats the purpose entirely.
  • @empty replaces the *ngIf plus length === 0 workaround 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>
}
PipeBehaviourWatch out
dateFormats a dateNeeds a locale provider for non-English output
currencyFormats moneyPass the currency code explicitly rather than relying on the default
sliceSlices an array or stringOn a string, indexing is character-based, not grapheme-based
asyncSubscribes and unwrapsEach use is a separate subscription — pipe once, bind many times
jsonDebug outputRemove before shipping; it is a serialisation cost on every change detection pass
DecimalPipeNumeric formattingThe 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()" />
}
TriggerFires whenTypical use
on idleThe browser is idleBelow-the-fold content that is almost always wanted
on viewportThe placeholder enters the viewportLong pages with heavy sections
on interactionA click, keypress or focus on a targetTabs, dialogs, expandable panels
on hoverThe pointer hovers the placeholderMenus with expensive content
on timer(5s)After a delayNon-critical widgets
when exprAn expression becomes truthyExplicit control you already have as state
  • @placeholder is what the user sees before the trigger, so give it the real size of the content or the page will visibly jump.
  • minimum on @loading stops a flash of the loading state when the chunk arrives quickly.
  • prefetch on idle downloads 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.

Signals, computed values and effects Change detection, zoneless and performance

Last refreshed 2026-09-18.