Angular

Signals

signal, computed, effect, linkedSignal, resource.

Vérifié en septembre 2026 · Angular 22.1 · environ 16 min

Un signal est une valeur qui sait qui la lit. Cette seule propriété change la façon dont Angular met l'écran à jour : au lieu de tout relire après chaque événement pour découvrir ce qui a bougé, le framework sait, au moment même de l'écriture, quelles vues dépendent de la valeur modifiée. Depuis Angular 21, une application neuve n'embarque plus Zone.js et repose sur des notifications explicites, dont les signals sont la principale ; depuis Angular 22, OnPush est la stratégie par défaut des composants. Le cours part du coût que les signals suppriment, puis suit les primitives dans l'ordre où on en a besoin : signal et computed pour l'état synchrone, effect pour sortir du graphe, linkedSignal et resource pour les deux cas que computed ne couvre pas.

Le problème que les signals résolvent

Avant les signals, Angular ne savait pas quand l'état changeait. Zone.js comblait ce manque en enveloppant les API asynchrones du navigateur — setTimeout, addEventListener, les promesses, XMLHttpRequest — pour prévenir Angular à la fin de chaque tâche. Angular lançait alors une détection de changements sur l'arbre entier, depuis la racine : dans chaque composant, chaque liaison du template était réévaluée et comparée à sa valeur précédente, et le DOM n'était touché que là où elles différaient.

Le coût de ce balayage est proportionnel au nombre de liaisons de l'application, pas au nombre de choses qui ont changé. Un mousemove écouté n'importe où, un minuteur d'une bibliothèque tierce, et toute l'application est relue : Zone.js ignore si la tâche a modifié quoi que ce soit, il sait seulement qu'elle s'est terminée. OnPush limitait les dégâts en sautant les sous-arbres dont les entrées n'avaient pas changé, au prix d'une discipline : entrées immuables, markForCheck appelé à la main, pipe async partout.

Un signal renverse la question. Le lire dans un contexte réactif — un template, un computed, un effect — inscrit le lecteur comme consommateur ; y écrire prévient ses consommateurs. Angular n'a plus à deviner : quand un signal lu par un template change, la vue concernée est marquée à rafraîchir et une détection de changements est planifiée, qui ne réévalue que les vues marquées. Un parent dont le template ne lit pas ce signal n'est pas réévalué, même quand son enfant l'est.

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

// Deux compteurs pour voir quels templates Angular reevalue.
export const evaluations = { entete: 0, badge: 0 };

@Component({
  selector: 'app-badge',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<span>{{ libelle() }}</span>`,
})
export class Badge {
  readonly nonLus = signal(0);

  // Une methode appelee depuis le template s'execute a chaque reevaluation de
  // la vue ; le signal qu'elle lit n'en est pas moins suivi.
  protected libelle(): string {
    evaluations.badge++;
    return this.nonLus() + ' non lus';
  }
}

@Component({
  selector: 'app-entete',
  imports: [Badge],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<h1>{{ titre() }}</h1><app-badge />`,
})
export class Entete {
  protected titre(): string {
    evaluations.entete++;
    return 'Messagerie';
  }
}

// Premier rendu              : entete 1, badge 1
// badge.nonLus.set(5), rendu : entete 1, badge 2, et l'ecran affiche « 5 non lus »
//
// Seule la vue qui lit nonLus est reevaluee ; l'en-tete, qui la contient, ne
// l'est pas. La strategie Default de l'ere Zone.js aurait relu les deux
// templates, et avec eux tous ceux de l'application.

signal et computed

signal(valeur) crée un WritableSignal : une fonction qu'on appelle pour lire, dotée de set, qui remplace la valeur, et de update, qui la calcule depuis la précédente. computed dérive une valeur d'autres signals et ne s'écrit pas : set n'existe pas sur son type, l'erreur tombe à la compilation.

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

interface Ligne {
  readonly reference: string;
  readonly prix: number;
}

@Component({
  selector: 'app-panier',
  templateUrl: './panier.html',
  // La valeur par defaut depuis Angular 22 : l'ecrire ne change rien, sinon
  // que le lecteur n'a pas a s'en souvenir.
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Panier {
  // Un signal est une valeur dans une boite, qu'on lit en l'appelant.
  // protected le reserve a la classe, a ses sous-classes et a son template.
  protected readonly lignes = signal<readonly Ligne[]>([]);
  protected readonly compteur = signal(0);

  // Un computed se derive d'autres signals. Il est en lecture seule (pas de
  // set), calcule a la premiere lecture, puis garde en cache tant qu'aucune
  // de ses sources ne change.
  protected readonly total = computed(() =>
    this.lignes().reduce((somme, ligne) => somme + ligne.prix, 0),
  );
  protected readonly estVide = computed(() => this.lignes().length === 0);

  protected ajouter(reference: string, prix: number): void {
    // update part de la valeur courante. Le tableau est remplace, jamais
    // modifie en place : un push sur le meme tableau garderait la meme
    // reference, et un signal compare par reference (Object.is).
    this.lignes.update((lignes) => [...lignes, { reference, prix }]);
    this.compteur.update((n) => n + 1);
  }

  protected vider(): void {
    // set remplace la valeur sans regarder l'ancienne. Le compteur, lui,
    // n'est pas une derivee du panier : il compte les ajouts depuis
    // l'ouverture, et vider ne le remet pas a zero.
    this.lignes.set([]);
  }
}

Le template lit les signals en les appelant. Les parenthèses sont la lecture, et c'est cette lecture qui abonne la vue : sans elles, l'interpolation recevrait la fonction elle-même et ne s'abonnerait à rien. Le diagnostic étendu NG8109 signale l'oubli dès la compilation.

<!-- Chaque signal lu ici fait de la vue une consommatrice de ce signal :
     c'est lui, et non un balayage, qui dira a Angular de la rafraichir. -->
<p>Articles ajoutes : {{ compteur() }}</p>

@if (estVide()) {
  <p>Le panier est vide.</p>
} @else {
  <ul>
    @for (ligne of lignes(); track $index) {
      <li>{{ ligne.reference }} : {{ ligne.prix }} €</li>
    }
  </ul>
  <p>Total : {{ total() }} €</p>
  <button type="button" (click)="vider()">Vider</button>
}

<button type="button" (click)="ajouter('USB-64', 12)">Ajouter une cle USB</button>

Deux propriétés de computed font son intérêt. Il est paresseux : sa fonction ne s'exécute qu'à la première lecture, et une écriture dans une source ne le recalcule pas, elle le marque périmé. Il est mémoïsé : tant qu'aucune source n'a changé, le relire rend la valeur en cache sans rien exécuter. Filtrer ou trier une liste dans un computed ne coûte donc qu'une fois par changement de la liste, quel que soit le nombre de lectures, là où une méthode appelée depuis le template s'exécutait à chaque passage de la détection de changements.

L'ensemble forme un graphe : les signals écrivables en sont les sources, les computed les nœuds intérieurs, les templates et les effects les feuilles. La notification descend du signal écrit vers ses consommateurs, qui ne font que se marquer périmés ; la valeur, elle, remonte à la demande, quand quelqu'un la lit. Cette séparation garantit qu'un computed qui lit deux dérivées d'une même source ne voit jamais l'une à jour et l'autre en retard.

Le suivi des dépendances

Le suivi est dynamique : un computed dépend des signals que sa dernière exécution a lus, pas de ceux qui figurent dans son code. Aucune analyse statique n'intervient ; Angular enregistre chaque lecture pendant l'exécution et remplace la liste précédente à chaque recalcul. Une branche non prise ne crée donc aucune dépendance.

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

const afficherRemise = signal(false);
const prix = signal(100);
const remise = signal(10);

let evaluations = 0;

const libelle = computed(() => {
  evaluations++;

  // remise n'est lue que dans la premiere branche. Tant que la condition est
  // fausse, elle n'est pas une dependance de libelle : le graphe ne contient
  // que ce que la derniere execution a reellement lu.
  return afficherRemise() ? `${prix() - remise()} € (remise ${remise()} €)` : `${prix()} €`;
});

console.log(libelle(), evaluations); // 100 € 1

remise.set(20);
console.log(libelle(), evaluations); // 100 € 1 : rien n'a ete recalcule

afficherRemise.set(true);
console.log(libelle(), evaluations); // 80 € (remise 20 €) 2

// La branche est lue, remise vient d'entrer dans le graphe.
remise.set(30);
console.log(libelle(), evaluations); // 70 € (remise 30 €) 3

// Et elle en ressort des qu'elle n'est plus lue.
afficherRemise.set(false);
remise.set(40);
console.log(libelle(), evaluations); // 100 € 4 : une seule evaluation pour deux ecritures

C'est un comportement voulu, et le plus souvent une économie : une valeur qui n'a pas servi au résultat ne peut pas le rendre faux. Le template suit exactement la même règle, ce qui fait de @if un filtre de dépendances autant qu'un filtre d'affichage.

<!-- Le template est lui aussi un contexte reactif, avec la meme regle : seul ce
     qui a ete lu au dernier rendu est suivi. Tant que connecte() est faux,
     utilisateur() n'est jamais lu, et le changer ne rafraichit pas la vue. -->
@if (connecte()) {
  <p>Bonjour {{ utilisateur().nom }}</p>
} @else {
  <p>Connectez-vous.</p>
}

Ce qui n'est pas suivi se range en quatre cas.

  • Un champ ordinaire. Seul un signal notifie. Une propriété de classe lue dans un computed y reste figée à la valeur du dernier calcul.
  • Une mutation en place. Un signal compare ses valeurs par Object.is : un push suivi d'un set du même tableau ne change pas la référence, et rien n'est notifié. On remplace, on ne mute pas.
  • Une lecture après await. Le contexte réactif n'existe que pendant l'exécution synchrone ; un signal lu dans la suite d'une promesse ou dans un setTimeout n'est rattaché à rien.
  • Une lecture sous untracked. C'est le cas voulu : lire une valeur au passage sans en faire une dépendance.
import { computed, signal, untracked } from '@angular/core';

// 1. Une mutation en place ne notifie personne.
const references = signal(['USB-64', 'HDMI-2']);
const nombre = computed(() => references().length);

console.log(nombre()); // 2

references().push('DP-14');
references.set(references()); // meme tableau : Object.is le juge inchange
console.log(nombre()); // 2 : le tableau en contient trois, le computed l'ignore

references.update((liste) => [...liste, 'VGA-1']); // nouvelle reference
console.log(nombre()); // 4

// 2. untracked lit sans s'abonner.
const quantite = signal(2);
const tauxTva = signal(20);
let evaluations = 0;

const montant = computed(() => {
  evaluations++;
  return (quantite() * 10 * (100 + untracked(tauxTva))) / 100;
});

console.log(montant(), evaluations); // 24 1

tauxTva.set(10);
console.log(montant(), evaluations); // 24 1 : le taux n'est pas une dependance

quantite.set(3);
console.log(montant(), evaluations); // 33 2 : relu au passage, le taux vaut bien 10

// 3. Une valeur qui ne change pas arrete la propagation.
const stock = signal(7);
const enRupture = computed(() => stock() === 0);
let rendus = 0;
const bandeau = computed(() => {
  rendus++;
  return enRupture() ? 'Rupture' : 'En stock';
});

console.log(bandeau(), rendus); // En stock 1
stock.set(6);
console.log(bandeau(), rendus); // En stock 1 : enRupture est recalcule, reste false,
//                                 et bandeau n'est pas reevalue

Le troisième bloc montre la propriété inverse, tout aussi utile : un computed recalculé qui rend la même valeur qu'avant arrête la propagation. Ses propres consommateurs ne sont pas réévalués, si bien qu'un computed booléen intermédiaire protège tout ce qui est en aval des variations qui ne changent pas sa réponse.

effect

effect exécute une fonction une première fois, suit les signals qu'elle lit comme le ferait un computed, et la réexécute quand l'un d'eux change. La différence tient à ce qu'il rend : rien. Un computed produit une valeur que d'autres lisent ; un effect ne produit qu'une conséquence, hors du graphe. Son usage légitime est donc la frontière : recopier un signal vers ce qui n'en est pas un — localStorage, le titre du document, un canvas, un journal.

Il s'exécute de façon asynchrone, pendant la détection de changements : un effect créé dans un composant passe juste avant la vérification de ce composant, et trois écritures successives ne provoquent qu'une exécution, avec la dernière valeur. Il se crée dans un contexte d'injection — le constructeur, ou avec l'option injector — parce qu'il s'attache au DestroyRef qui le détruira avec son composant.

Le contre-emploi classique consiste à propager de l'état par un effect : dès qu'un effect écrit dans un signal, il fait le travail d'un computed, en moins bien. Le cas extrême est celui qui écrit dans un signal dont il dépend.

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

interface Commande {
  readonly numero: number;
  readonly montant: number;
}

@Component({
  selector: 'app-commandes',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (commande of commandes(); track commande.numero) {
      <p>{{ commande.numero }} : {{ commande.montant }} €</p>
    }
  `,
})
export class Commandes {
  readonly commandes = signal<readonly Commande[]>([
    { numero: 3, montant: 40 },
    { numero: 1, montant: 15 },
    { numero: 2, montant: 90 },
  ]);

  constructor() {
    // « Garder la liste triee. » L'effect lit commandes, donc en depend, puis
    // ecrit dans commandes. Le tri rend un nouveau tableau a chaque passage,
    // qu'Object.is juge different du precedent meme quand l'ordre n'a pas
    // bouge : l'ecriture invalide l'effect, qui repasse, reecrit, repasse...
    // Aucune erreur ne vient l'arreter : Angular relance les effects sales
    // d'une vue tant qu'il en reste, sans plafond, et l'onglet se fige.
    effect(() => {
      this.commandes.set([...this.commandes()].sort((a, b) => a.numero - b.numero));
    });
  }
}

Le symptôme n'est pas une erreur mais un gel : Angular relance les effects périmés d'une vue tant qu'il en reste, sans limite. Même sans boucle, un effect qui recopie une valeur dans un autre signal laisse deux sources de vérité, dont l'une a toujours un temps de retard. La correction consiste à ne rien écrire : ce qui se déduit se déclare en computed.

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

interface Commande {
  readonly numero: number;
  readonly montant: number;
}

@Component({
  selector: 'app-commandes',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (commande of commandesTriees(); track commande.numero) {
      <p>{{ commande.numero }} : {{ commande.montant }} €</p>
    }
  `,
})
export class Commandes {
  // La source reste telle qu'elle arrive.
  readonly commandes = signal<readonly Commande[]>([
    { numero: 3, montant: 40 },
    { numero: 1, montant: 15 },
    { numero: 2, montant: 90 },
  ]);

  // La vue lit une derivee. Rien n'est ecrit, donc rien ne boucle ; le tri
  // n'est refait que lorsque commandes change, et seulement si quelqu'un lit
  // le resultat. Il n'existe plus deux versions de la liste a tenir d'accord.
  readonly commandesTriees = computed(() =>
    [...this.commandes()].sort((a, b) => a.numero - b.numero),
  );
}

Le nettoyage est l'autre moitié du contrat. La fonction d'un effect reçoit onCleanup, qui inscrit un rappel exécuté avant l'exécution suivante et à la destruction de l'effect : c'est là qu'on annule le minuteur, la souscription ou la requête que l'exécution précédente a lancés et que la nouvelle rend caducs.

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

@Component({
  selector: 'app-brouillon',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<textarea #zone [value]="texte()" (input)="texte.set(zone.value)"></textarea>`,
})
export class Brouillon {
  readonly texte = signal(localStorage.getItem('brouillon') ?? '');

  constructor() {
    // Le cas d'ecole : recopier un signal vers une API qui n'en est pas un.
    // effect n'est permis que dans un contexte d'injection (ici le
    // constructeur) et il est detruit avec le composant.
    effect((onCleanup) => {
      const texte = this.texte();

      // Rien n'est ecrit tant que la frappe continue : chaque nouvelle valeur
      // relance l'effect, et onCleanup annule d'abord le minuteur de la
      // precedente. Il est aussi appele a la destruction du composant.
      const minuteur = setTimeout(() => localStorage.setItem('brouillon', texte), 500);
      onCleanup(() => clearTimeout(minuteur));
    });
  }
}

Quand l'effect doit toucher le DOM que le template vient de produire — mesurer un élément, piloter une bibliothèque de graphiques —, afterRenderEffect prend le relais : effect s'exécute avant la mise à jour du DOM, afterRenderEffect après.

linkedSignal

Deux primitives laissent un trou entre elles. Un computed suit sa source mais ne s'écrit pas ; un signal s'écrit mais ne suit rien. Une sélection dans une liste reçue du parent a besoin des deux : l'utilisateur la change au clic, et elle doit se réaligner quand la liste change, faute de quoi elle désigne un élément qui n'existe plus. La réponse d'avant, un effect qui réécrit la sélection à chaque nouvelle liste, est précisément le motif que la section précédente déconseille.

linkedSignal est un signal écrivable dont la valeur est recalculée chaque fois que sa source change. Entre deux changements, set et update fonctionnent comme sur un signal ordinaire ; au changement suivant, le calcul reprend la main. La forme courte, linkedSignal(() => liste()[0]), repart toujours du calcul. La forme longue sépare source et computation, et passe au calcul la valeur précédente — y compris celle posée par set —, ce qui permet de conserver le choix de l'utilisateur tant qu'il reste valide.

import { ChangeDetectionStrategy, Component, input, linkedSignal } from '@angular/core';

interface Produit {
  readonly reference: string;
  readonly libelle: string;
}

@Component({
  selector: 'app-liste-produits',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @for (produit of produits(); track produit.reference) {
      <button
        type="button"
        [class.actif]="produit.reference === selection()?.reference"
        (click)="selection.set(produit)"
      >
        {{ produit.libelle }}
      </button>
    }
    <p>Selection : {{ selection()?.libelle ?? 'aucune' }}</p>
  `,
})
export class ListeProduits {
  // La liste vient du parent : le composant ne la possede pas.
  readonly produits = input.required<readonly Produit[]>();

  // La selection, elle, est un etat local qu'on ecrit au clic, mais qui doit
  // suivre la source. Un computed ne s'ecrit pas ; un signal ne se realigne
  // pas. linkedSignal est les deux : ecrivable, et recalcule quand source
  // change.
  readonly selection = linkedSignal<readonly Produit[], Produit | undefined>({
    source: this.produits,
    // precedent porte l'ancienne source et l'ancienne valeur, y compris celle
    // posee par set. On garde le choix de l'utilisateur tant qu'il existe
    // encore dans la nouvelle liste ; sinon, on retombe sur le premier.
    computation: (produits, precedent) =>
      produits.find((p) => p.reference === precedent?.value?.reference) ?? produits[0],
  });
}

Avec les listes successives [clavier, souris, écran], où l'utilisateur choisit l'écran, puis [écran, casque], puis [casque, micro], la sélection vaut écran, reste écran, puis retombe sur casque. Une liste vide donne undefined, que le type Produit | undefined oblige le template à traiter.

resource

Tous les signals sont synchrones : une donnée qui arrive plus tard ne peut pas être un computed. resource, stable depuis Angular 22, fait le pont. Il prend une fonction params, suivie comme un computed, et un loader asynchrone rappelé à chaque nouvelle valeur de params. Le résultat s'expose en signals : value, status, error, isLoading, et la méthode hasValue().

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

interface Produit {
  readonly reference: string;
  readonly libelle: string;
}

@Component({
  selector: 'app-recherche',
  templateUrl: './recherche.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Recherche {
  readonly terme = signal('');

  readonly donnees = resource({
    // params est suivi comme un computed : chaque nouvelle valeur relance le
    // chargement. Rendre undefined laisse la resource au repos (statut
    // 'idle') sans appeler le loader.
    params: () => {
      const terme = this.terme().trim();
      return terme.length >= 2 ? terme : undefined;
    },

    // Le loader recoit la valeur de params, jamais undefined. Si params change
    // pendant qu'il travaille, abortSignal est declenche et le resultat de
    // l'ancien appel n'arrivera jamais dans value.
    loader: async ({ params, abortSignal }) => {
      const reponse = await fetch(`/api/produits?q=${encodeURIComponent(params)}`, {
        signal: abortSignal,
      });
      if (!reponse.ok) {
        throw new Error(`Recherche impossible (${reponse.status})`);
      }
      return (await reponse.json()) as Produit[];
    },

    // Sans valeur par defaut, value serait Produit[] | undefined.
    defaultValue: [],
  });
}

Deux comportements font son intérêt face à un switchMap écrit à la main. Si params rend undefined, le loader ne part pas et le statut reste 'idle'. Si params change pendant un chargement, le signal d'annulation de l'appel en cours est déclenché, et sa réponse, même si elle finit par arriver, n'atteint jamais value : une réponse lente ne peut plus écraser une réponse récente.

Une règle en découle, qui est une contrainte et non un avantage : le loader n'est pas suivi. Un signal lu à l'intérieur ne relance rien ; tout ce dont le chargement dépend passe par params.

StatutSituationvalue()
'idle'params rend undefinedla valeur par défaut
'loading'params a changé, le loader tournela valeur par défaut
'reloading'reload() a été appeléla valeur précédente
'resolved'le loader a rendu sa valeurcette valeur
'error'le loader a levé une exceptionlève une exception
'local'set ou update a été appeléla valeur posée

La ligne 'error' commande l'ordre du template : dans ce statut, lire value() lève une exception au lieu de rendre la valeur par défaut. L'erreur se teste donc avant la valeur, jamais après.

<input #champ type="search" [value]="terme()" (input)="terme.set(champ.value)" />

<!-- L'erreur se teste avant la valeur : en statut 'error', lire value()
     leve une exception au lieu de rendre la valeur par defaut. -->
@if (donnees.isLoading()) {
  <p>Recherche en cours...</p>
} @else if (donnees.error(); as erreur) {
  <p>{{ erreur.message }}</p>
  <button type="button" (click)="donnees.reload()">Reessayer</button>
} @else {
  @for (produit of donnees.value(); track produit.reference) {
    <p>{{ produit.reference }} : {{ produit.libelle }}</p>
  } @empty {
    <p>Aucun resultat.</p>
  }
}

resource sert à lire, pas à écrire : l'annulation automatique qui protège une recherche interromprait une requête de modification au milieu de son trajet. Une création ou une suppression reste un appel explicite, dans un gestionnaire d'événement.

Zoneless

Sans Zone.js, plus rien n'intercepte les tâches asynchrones, et Angular ne lance plus de détection de changements « au cas où ». Elle est planifiée par des notifications explicites : une écriture dans un signal lu par un template, un gestionnaire d'événement lié dans un template, markForCheck — que le pipe async appelle —, ComponentRef.setInput, l'attachement d'une vue marquée. Depuis Angular 21, c'est le mode par défaut ; provideZonelessChangeDetection() reste disponible pour l'écrire explicitement.

import {
  ApplicationConfig,
  provideBrowserGlobalErrorListeners,
  provideZonelessChangeDetection,
} from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    // Depuis Angular 21, c'est deja le fonctionnement par defaut d'une
    // application : cette ligne le rend explicite, elle ne l'active pas.
    // Ce qu'elle remplace, c'est provideZoneChangeDetection(), qu'il faut
    // chercher et retirer dans une application migree, avec zone.js dans les
    // polyfills d'angular.json et la dependance zone.js du package.json.
    provideZonelessChangeDetection(),

    // Zone.js renvoyait a l'ErrorHandler les erreurs nees dans sa zone, meme
    // dans un setTimeout. Sans lui, elles remontent a window : ce fournisseur,
    // que le CLI ajoute aux nouvelles applications, ecoute 'error' et
    // 'unhandledrejection' et les fait suivre a l'ErrorHandler.
    provideBrowserGlobalErrorListeners(),

    provideRouter(routes),
  ],
};

Le démarrage y gagne ce que Zone.js coûtait : un polyfill de moins à charger avant l'application, des API du navigateur laissées intactes, des piles d'appels lisibles. En contrepartie, les composants en stratégie Default — nommée Eager depuis la 21.2 — qui comptaient sur le balayage cessent de se rafraîchir : un champ ordinaire modifié dans un minuteur ne prévient personne, et en développement le prochain rafraîchissement déclenché ailleurs le signale par une erreur NG0100. En OnPush, le même code ne marchait déjà pas sous Zone.js, dont le balayage sautait les composants non marqués ; il échoue désormais sans la moindre erreur.

import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core';

@Component({
  selector: 'app-horloge',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<p>Ouvert depuis {{ secondes }} s</p>`,
})
export class Horloge {
  secondes = 0;

  constructor() {
    // Sous Zone.js, ce code ne marchait qu'en strategie Default : le tick
    // declenche par le setInterval parcourait l'arbre, mais sautait les
    // composants OnPush non marques. Depuis Angular 22, OnPush par defaut et
    // l'absence de Zone.js le condamnent chacun de son cote. Un champ
    // ordinaire qui change ne previent personne : l'ecran reste sur « 0 s »
    // alors que le champ, lui, avance, et aucune erreur ne le signale.
    const minuteur = setInterval(() => this.secondes++, 1000);
    inject(DestroyRef).onDestroy(() => clearInterval(minuteur));
  }
}

Le même composant avec un signal fonctionne, et pour une raison précise, pas par chance : l'écriture est la notification.

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

@Component({
  selector: 'app-horloge',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<p>Ouvert depuis {{ secondes() }} s</p>`,
})
export class Horloge {
  readonly secondes = signal(0);

  constructor() {
    // L'ecriture dans un signal lu par le template est une notification : elle
    // marque cette vue a rafraichir et planifie une detection de changements,
    // qui ne passera que sur ce qui a ete marque.
    const minuteur = setInterval(() => this.secondes.update((s) => s + 1), 1000);
    inject(DestroyRef).onDestroy(() => clearInterval(minuteur));
  }
}

L'erreur se confond facilement avec un bogue d'affichage parce qu'elle est intermittente : qu'un signal voisin rafraîchisse la même vue, et le champ apparaît à jour, par ricochet. Un composant correct en zoneless est un composant dont tout l'état affiché est un signal, une entrée ou le résultat d'un pipe async. Les observables NgZone.onStable et NgZone.onMicrotaskEmpty n'émettent plus jamais ; ce qu'ils servaient à attendre se réécrit avec afterNextRender.

Ce cours vous a servi ? Offrir un café Signaler une erreur