Control flow, OnPush and zoneless mode

Key takeaway — The new control flow (@if, @for, @switch) pairs natively with signals. With OnPush and, eventually, zoneless mode, Angular only refreshes the views whose signal actually changed.

Signals truly come into their own at render time. This chapter ties reactive state to Angular's modern template and explains how to achieve minimal, performant change detection.

The new control flow

Since Angular 17, the *ngIf and *ngFor directives give way to a syntax built into the template, more readable and faster. It reads signals directly.

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    @if (taches().length === 0) {
      <p>No tasks. 🎉</p>
    } @else {
      <ul>
        @for (tache of taches(); track tache.id) {
          <li>{{ tache.texte }}</li>
        } @empty {
          <li>Empty list</li>
        }
      </ul>
    }
  `,
})

Key points:

  • @for requires track. The tracking expression (often track item.id) lets Angular reuse DOM nodes instead of recreating everything. It is mandatory and crucial for performance.
  • @empty handles the empty-list case, without an extra @if.
  • @switch replaces [ngSwitch]:
@switch (statut()) {
  @case ('chargement') { <app-spinner /> }
  @case ('erreur')     { <p>An error occurred.</p> }
  @default             { <app-liste [donnees]="donnees()" /> }
}

Full example: a reactive task list

import { Component, signal, computed, ChangeDetectionStrategy } from '@angular/core';

interface Tache {
  id: number;
  texte: string;
  faite: boolean;
}

@Component({
  selector: 'app-root',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <h1>Tasks</h1>
    <input
      #champ
      (keyup.enter)="ajouter(champ.value); champ.value = ''"
      placeholder="New task then Enter…"
    />

    @if (taches().length === 0) {
      <p>No tasks for now. 🎉</p>
    } @else {
      <ul>
        @for (tache of taches(); track tache.id) {
          <li>
            <label>
              <input
                type="checkbox"
                [checked]="tache.faite"
                (change)="basculer(tache.id)"
              />
              {{ tache.texte }}
            </label>
          </li>
        }
      </ul>
      <p>{{ restantes() }} task(s) remaining</p>
    }
  `,
})
export class AppComponent {
  private prochainId = 1;

  readonly taches = signal<Tache[]>([]);
  readonly restantes = computed(() => this.taches().filter((t) => !t.faite).length);

  ajouter(texte: string): void {
    const valeur = texte.trim();
    if (!valeur) return;
    this.taches.update((liste) => [
      ...liste,
      { id: this.prochainId++, texte: valeur, faite: false },
    ]);
  }

  basculer(id: number): void {
    this.taches.update((liste) =>
      liste.map((t) => (t.id === id ? { ...t, faite: !t.faite } : t)),
    );
  }
}

Try it live

control-flow-taches

Why pair signals and OnPush?

The ChangeDetectionStrategy.OnPush strategy tells Angular not to re-check a component on every global cycle, but only when needed. Now, signals notify precisely the affected view on every change. The combination is ideal:

@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  // …
})

With signals in the template, OnPush introduces no risk of a stale view: any change of a signal read by the view triggers its refresh. That is why all of this course's demos use OnPush. Make it a habit from now on.

Zoneless mode

Historically, Angular detects changes thanks to Zone.js, which "patches" the browser APIs (timers, events, fetch) to trigger detection. It is convenient but global and expensive.

Since signals signal their own changes, Angular can do without Zone.js: that is zoneless mode (stabilized in Angular 20, available in preview before). You enable it at bootstrap:

import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';

bootstrapApplication(AppComponent, {
  providers: [provideZonelessChangeDetection()],
});

Benefits: lighter bundle (you remove zone.js), more predictable and often faster rendering. Condition: your reactivity must flow through signals (or explicit notifications) rather than "magic" mutations detected by Zone.

In zoneless, a state mutated outside a signal (for example a class property modified in a setTimeout) no longer refreshes the view automatically. Migrating to signals then becomes the default best practice.

Performance: the right reflexes

  • Relevant track in every @for (a stable identifier, never the index if the list changes order).
  • computed rather than recomputing in the template (a heavy inline calculation would be re-evaluated on every render).
  • OnPush everywhere: with signals, it is free in terms of safety.
  • Split large components: the smaller the views, the more targeted the updates.

Key points

  • @if / @for (with track) / @switch / @empty: modern control flow that reads signals.
  • OnPush + signals = targeted refreshes, without stale views.
  • Zoneless mode removes Zone.js and relies on signals.
  • track, computed and splitting are the performance levers.

Last chapter: assembling all of this into a clean state architecture — signal-based service, linkedSignal, and production best practices.

We use Microsoft Clarity to understand how the site is used and improve it. By continuing to browse, you accept it. You can disable it at any time.