Front-end angularforall.com

- ChangeDetectionStrategy en Angular : DetectChanges et OnPush

Angular Change-Detection Onpush Detectchanges Markforcheck Changedetectorref Zone-Js Zoneless Signals Performance Immutability Async-Pipe
ChangeDetectionStrategy en Angular : DetectChanges et OnPush

Maitrisez le Change Detection Angular avec OnPush, markForCheck, detectChanges, Signals et zoneless pour des cycles de rendu rapides en production.

Pourquoi le Change Detection existe-t-il ?

Le Change Detection est le mécanisme qui synchronise l'état JavaScript d'un composant avec le DOM visible à l'écran. Quand this.count = 5 devient {{ count }} dans un template, c'est lui qui s'en charge. Angular doit savoir quand vérifier qu'une variable a changé, et surtout quels composants il faut re-rendre. Mal configuré, c'est la première source de jank sur les grands écrans.

Le rôle de ChangeDetectionStrategy est de répondre à ces deux questions au niveau de chaque composant. Deux valeurs possibles : Default (vérifier tout, tout le temps) ou OnPush (ne vérifier que si une des conditions ci-dessous est remplie). Ce choix détermine combien d'opérations Angular effectue à chaque tick, et donc la fluidité ressentie par l'utilisateur.

L'origine du problème — pourquoi Angular ne sait pas tout seul

JavaScript ne dispose d'aucun mécanisme natif pour observer la mutation d'une variable arbitraire (sauf via Proxy, trop coûteux à grande échelle). Sans information du runtime, Angular n'a qu'une solution : revisiter périodiquement tous les bindings de son arbre de composants pour comparer leur valeur courante à leur snapshot précédent. C'est l'algorithme du dirty checking, hérité historiquement d'AngularJS et conservé dans Angular moderne pour les composants Default.

Cette stratégie est sûre — rien ne peut être manqué — mais elle paie le prix de cette sécurité par un travail proportionnel à la taille de l'arbre, qu'il y ait du changement ou non. OnPush répond en disant : « je te promets, en tant que développeur, que rien ne change ici si une de ces 5 conditions n'est pas remplie ; tu peux donc sauter ce sous-arbre en toute confiance ».

L'impact concret en production

  • Sur une dashboard avec 200 cartes : Default = ~28 ms par cycle, OnPush = ~3 ms. L'écart explose dès que les listes grossissent.
  • Un timer setInterval qui déclenche un tick global toutes les secondes : invisible sur 10 composants, désastreux sur 5 000.
  • Le coût n'est pas le rendu lui-même mais la traversée de l'arbre des composants par le Change Detector — qu'aucun ait changé ou non.
À retenir : Default convient à 80 % des écrans (formulaires, pages de détail). OnPush est obligatoire dès qu'on affiche des listes longues, des grilles, des composants imbriqués profonds, ou qu'on cherche un mode zoneless.

Comment fonctionne le Change Detection sous le capot

Pour bien régler OnPush, il faut comprendre l'algorithme. Angular maintient un arbre de LView (un par composant instancié). À chaque tick, il parcourt cet arbre en profondeur, de haut en bas, et pour chaque LView, il évalue les expressions de binding et compare au snapshot précédent (dirty checking).

Qui déclenche un tick ?

Par défaut, Zone.js patche toutes les API asynchrones du navigateur (setTimeout, addEventListener, XHR, Promise). Dès qu'un callback se termine dans la zone Angular, un tick() est planifié. L'arbre entier est revisité. C'est pratique mais coûteux.

Schéma simplifié du tick Default

// Pseudo-code Angular Default
function tick(rootView) {
  // On parcourt TOUS les composants, marqués ou non
  walk(rootView, (view) => {
    if (!view.detached) {
      refreshView(view); // évalue les bindings, met à jour le DOM
    }
  });
}

Avec OnPush, deux drapeaux sont ajoutés par composant

// Pseudo-code Angular OnPush
function tick(rootView) {
  walk(rootView, (view) => {
    if (view.detached) return;

    // Si le composant est OnPush, on saute SAUF si on l'a marqué "dirty"
    if (view.strategy === 'OnPush' && !view.dirty && !ancestorIsDirty(view)) {
      return; // gain massif sur les sous-arbres figés
    }

    refreshView(view);
    view.dirty = false;
  });
}

C'est cette ligne if (...) return qui vaut tous les efforts d'immuabilité décrits plus loin. Un composant OnPush propre fait économiser le rendu de tout son sous-arbre, à chaque tick.

Le rôle exact de Zone.js

Zone.js est un monkey-patch global du runtime navigateur. Il remplace les versions natives de setTimeout, setInterval, Promise.then, addEventListener, XMLHttpRequest, fetch par des wrappers qui notifient Angular dès qu'un callback s'exécute dans la zone. Le déclenchement du tick() est piloté par NgZone.onMicrotaskEmpty, qui détecte la fin d'une rafale d'opérations asynchrones.

Cette approche présente trois inconvénients sérieux : (1) le patch alourdit le bundle initial d'environ 30 ko, (2) chaque interaction déclenche un parcours complet de l'arbre des composants, et (3) il devient impossible de raisonner finement sur quels composants doivent réellement se re-rendre. OnPush atténue le problème ; Signals + zoneless le supprime totalement.

Default vs OnPush — comparaison détaillée

Default — comportement par défaut

// counter.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  // Pas de changeDetection — donc Default
  template: `<p>Compteur : {{ count }}</p> <button (click)="count++">+</button>`,
})
export class CounterComponent {
  count = 0;
}

À chaque clic, Zone.js intercepte l'événement et déclenche un tick. Angular vérifie ce composant et tous les autres composants de l'application. Aucun n'a changé : tant pis, le travail est fait pour rien.

OnPush — comportement opt-in

// user-card.component.ts
import { Component, ChangeDetectionStrategy, input } from '@angular/core';

@Component({
  selector: 'app-user-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<h3>{{ user().name }}</h3>`,
})
export class UserCardComponent {
  user = input.required<{ name: string }>();
}

Ici, Angular ignorera ce composant à chaque tick, sauf si la signal user() change. La consommation CPU sur une liste de 1 000 cartes est divisée par 10 à 20.

Mesure réelle (Angular 18, Chrome) :
  • 200 cartes Default : ~28 ms / tick
  • 200 cartes OnPush + immuabilité : ~2,5 ms / tick
  • Gain : ~91 % de temps CPU par cycle

Les 5 déclencheurs OnPush à connaître par cœur

Un composant OnPush n'est revisité que dans ces cinq cas — c'est tout ce qu'il faut retenir.

  1. Une @Input() change par référence — assignation d'une nouvelle valeur (les mutations internes sont invisibles).
  2. Un événement émis depuis le composant ou un de ses descendants — click, output, ngSubmit, etc.
  3. Un async pipe émet dans le template — Angular marque automatiquement le composant et ses ancêtres.
  4. Un Signal lu dans le template change — depuis Angular 17, comportement équivalent au async pipe.
  5. Un appel manuel à ChangeDetectorRefmarkForCheck() ou detectChanges().

Exemple complet — les 5 cas dans un même composant

import { Component, ChangeDetectionStrategy, ChangeDetectorRef,
         inject, input, output, signal } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { interval, map } from 'rxjs';

@Component({
  selector: 'app-five-triggers',
  standalone: true,
  imports: [AsyncPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <!-- 1. Input change ref -->
    <p>Input : {{ data() }}</p>

    <!-- 2. Event handler -->
    <button (click)="notify.emit()">Émettre</button>

    <!-- 3. Async pipe -->
    <p>Horloge : {{ clock$ | async }}</p>

    <!-- 4. Signal dans template -->
    <p>Signal : {{ counter() }}</p>

    <!-- 5. Marquage manuel -->
    <button (click)="asyncExternal()">Source externe</button>
  `,
})
export class FiveTriggersComponent {
  private cdr = inject(ChangeDetectorRef);

  data    = input<string>('');          // 1
  notify  = output<void>();              // 2
  clock$  = interval(1000).pipe(map(() => new Date().toLocaleTimeString())); // 3
  counter = signal(0);                   // 4

  asyncExternal() {
    // Modification HORS contexte Angular (ex. WebSocket natif, lib externe)
    setTimeout(() => {
      this.counter.update(c => c + 1);
      // signal.update() suffit — Angular marquera tout seul.
      // Si on avait modifié une propriété non-signal :
      this.cdr.markForCheck(); // 5
    }, 0);
  }
}

L'API ChangeDetectorRef en profondeur

ChangeDetectorRef donne accès au Change Detector du composant courant. C'est l'arme à utiliser quand les triggers automatiques ne suffisent pas.

markForCheck() — propagation vers le haut

// Marque CE composant ET TOUS ses ancêtres comme "dirty".
// La détection ne se produit qu'au PROCHAIN tick.
this.cdr.markForCheck();

Usage typique : un callback déclenché par une lib externe (Socket.IO sans patch Zone, callback natif d'une carte Leaflet, etc.) qui modifie l'état.

detectChanges() — exécution immédiate

// Lance synchronement la détection sur CE composant et ses descendants.
// Ne remonte PAS vers les ancêtres.
this.cdr.detectChanges();

Usage typique : avant un screenshot, une mesure DOM, ou pour afficher quelque chose immédiatement après une saisie synchrone.

detach() / reattach() — désactivation totale

// Détache le composant de l'arbre de détection. Plus aucun tick ne le touche.
this.cdr.detach();

// Reconnecte le composant quand l'état doit redevenir visible.
this.cdr.reattach();

Pattern utile : un widget graphique géré par une lib tierce (D3, Three.js) qui s'auto-rend. On détache pour qu'Angular ne le re-render jamais.

Tableau récapitulatif

MéthodeQuandEffetCas d'usage
markForCheck()Prochain tickMarque ce composant + ancêtresUpdate async externe
detectChanges()Immédiat (sync)Re-rend ce composant + descendantsCapture, mesure DOM
detach()PermanentDésactive la détectionLib qui gère son rendu
reattach()PermanentRéactive après detach()Reprise du contrôle Angular

Le piège de la mutation et la règle d'immuabilité

C'est l'erreur n°1 sur OnPush : modifier une propriété sans changer la référence de l'objet. Angular compare les Inputs par référence (===). Si la référence est identique, il considère qu'il n'y a rien de neuf.

Le bug typique

// ❌ NE FONCTIONNE PAS avec OnPush
@Component({
  selector: 'app-parent',
  template: `<app-user-card [user]="user"></app-user-card>`,
})
export class ParentComponent {
  user = { name: 'Alice' };

  rename() {
    this.user.name = 'Bob';  // mutation in-place — même référence !
    // L'enfant OnPush ne se met pas à jour : Angular voit user === user.
  }
}

La correction immuable

// ✅ Fonctionne avec OnPush
rename() {
  // Nouvelle référence d'objet — déclenche le check de l'enfant
  this.user = { ...this.user, name: 'Bob' };
}

// Pour un tableau :
addItem(item: Todo) {
  this.todos = [...this.todos, item]; // jamais this.todos.push()
}

// Pour un Map :
update(key: string, value: number) {
  this.map = new Map(this.map).set(key, value); // jamais this.map.set()
}
Règle d'or : sur tout composant OnPush, considérez que les Inputs sont en lecture seule. Toute mise à jour passe par un spread ou un opérateur immuable. C'est ce qui permet à Angular de comparer en O(1) et de sauter le sous-arbre quand rien n'a changé.

Quand on a beaucoup de mises à jour

Sur une grosse structure imbriquée, le spread devient verbeux. Les solutions classiques :

  • Immer — écrit du code « mutable » qui produit un objet immuable en sortie.
  • NgRx / NgRx Signal Store — gère l'immuabilité au niveau du store, vos composants reçoivent toujours des références fraîches.
  • Signals — chaque signal.update() garantit une nouvelle référence si vous renvoyez un nouvel objet.
import { produce } from 'immer';

toggleDone(id: number) {
  this.todos = produce(this.todos, draft => {
    const todo = draft.find(t => t.id === id);
    if (todo) todo.done = !todo.done;
  });
  // produce() renvoie un NOUVEAU tableau si quelque chose a changé,
  // sinon il renvoie l'original tel quel — parfait pour OnPush.
}

OnPush + RxJS — async pipe et patterns avancés

L'async pipe est conçu pour OnPush : à chaque next d'un Observable, il appelle markForCheck() automatiquement et désabonne sur destruction du composant. C'est le pattern recommandé dans toute la documentation Angular.

Pattern de base — flux unique

@Component({
  selector: 'app-data',
  standalone: true,
  imports: [AsyncPipe, JsonPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    @if (data$ | async; as data) {
      <pre>{{ data | json }}</pre>
    } @else {
      <p>Chargement…</p>
    }
  `,
})
export class DataComponent {
  private http = inject(HttpClient);
  data$ = this.http.get('/api/data').pipe(
    // shareReplay(1) évite plusieurs appels HTTP si | async est utilisé plusieurs fois
    shareReplay({ bufferSize: 1, refCount: true })
  );
}

Pattern ViewModel — un seul async pipe

Sur des écrans complexes, on combine plusieurs sources en un seul vm$ et on ne fait qu'un seul | async dans le template. Plus lisible, plus performant.

readonly vm$ = combineLatest({
  user: this.user$,
  notifications: this.notifications$,
  permissions: this.permissions$,
}).pipe(
  map(({ user, notifications, permissions }) => ({
    fullName: `${user.firstName} ${user.lastName}`,
    unread: notifications.filter(n => !n.read).length,
    canEdit: permissions.includes('edit'),
  })),
  shareReplay(1)
);
<!-- template — un seul subscribe via | async -->
@if (vm$ | async; as vm) {
  <h2>{{ vm.fullName }}</h2>
  <span>{{ vm.unread }} non lues</span>
  @if (vm.canEdit) { <button>Éditer</button> }
}

OnPush + Signals — la combinaison gagnante

Depuis Angular 17, les Signals sont les triggers les plus efficaces du Change Detection. Quand un signal lu dans un template change, Angular sait exactement quels composants en dépendent — pas besoin de remonter l'arbre. La combinaison OnPush + Signals est devenue la valeur par défaut des nouveaux projets.

Pourquoi Signals est supérieur à async pipe pour OnPush

L'async pipe marque le composant et toute sa chaîne d'ancêtres comme dirty à chaque émission, par sécurité. Les Signals, eux, exploitent un système de dépendances fines (fine-grained reactivity) qui sait précisément quels composants lisent quelle valeur. Résultat : seul le composant concerné est revisité, sans propagation parasite vers le haut. Sur une feature complexe, c'est une réduction supplémentaire de 30 à 50 % du nombre de checks.

Autre avantage majeur : les Signals sont synchrones et lisibles via signal() à tout moment. Pas besoin de shareReplay, de gestion de subscription, ni de double async pipe dans le template. La logique du composant devient lisible comme une variable locale, mais reste réactive.

Composant entièrement piloté par Signals

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

@Component({
  selector: 'app-cart',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <p>Articles : {{ items().length }}</p>
    <p>Total : {{ total() }} €</p>
    <button (click)="add({ price: 19.9 })">Ajouter</button>
  `,
})
export class CartComponent {
  items = signal<{ price: number }[]>([]);

  // total est recalculé automatiquement quand items change,
  // et UNIQUEMENT à ce moment-là — pas à chaque tick.
  total = computed(() => this.items().reduce((s, i) => s + i.price, 0));

  add(item: { price: number }) {
    // signal.update() garantit une nouvelle référence — OnPush content
    this.items.update(arr => [...arr, item]);
  }
}

Migration progressive @Input → input()

// Avant — @Input classique, OnPush nécessite des spreads côté parent
@Input() user!: User;

// Après — input signal, déjà optimal pour OnPush
user = input.required<User>();
// Dans le template : {{ user().name }}

Le schématic ng generate @angular/core:signal-input-migration migre automatiquement votre projet. Cumulé à OnPush, c'est généralement l'optimisation à plus fort ROI sur une codebase Angular existante.

Zoneless Angular 18+ — la fin du tick global

Angular 18 a introduit le mode zoneless, et il devient stable en Angular 20+. Sans Zone.js, plus aucun patch automatique sur les API du navigateur — Angular ne sait plus quand un événement s'est produit globalement. La détection devient pilotée par : événements DOM template, async pipe, et Signals.

Activer zoneless

// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';

bootstrapApplication(AppComponent, {
  providers: [
    provideExperimentalZonelessChangeDetection(),
    // ... reste de la config
  ],
});

Conséquences pour OnPush

  • Les composants Default ne sont plus revisités automatiquement par un tick global — il faut leur fournir une raison explicite (Signal, async pipe, event).
  • setTimeout/setInterval ne déclenchent plus de détection. À remplacer par des Signals ou des Observables avec async pipe.
  • OnPush devient le comportement implicite — choisir Default n'apporte plus aucun avantage.
  • Les bundles sont plus petits (~~30 ko gagnés en supprimant Zone.js).
Préparer son code pour zoneless : migrer tous les composants vers OnPush, remplacer les @Input par input(), les EventEmitter par output(), et tout état mutable par des Signals. C'est exactement le travail que vous faites en optimisant pour OnPush aujourd'hui.

Mesurer l'impact avec Angular DevTools

Optimiser sans mesurer, c'est deviner. Angular DevTools (extension Chrome/Firefox officielle) fournit deux outils indispensables.

1. Le Profiler — durée des cycles de détection

  1. Ouvrir DevTools → onglet Angular → bouton Profiler.
  2. Cliquer sur Start recording, effectuer l'interaction problématique, puis Stop.
  3. Chaque barre représente un cycle. La hauteur = temps total. Le détail montre quels composants ont consommé le plus.

Cible : aucun cycle au-dessus de 16 ms (sinon, frame manqué à 60 fps). Si des composants apparaissent à chaque tick alors qu'ils n'ont pas changé, ils sont en Default et candidats à OnPush.

2. ng.profiler — benchmark en console

// À exécuter dans la console Chrome, sur une page en mode dev
ng.profiler.timeChangeDetection({ record: true });
// → durée moyenne d'un cycle complet, sur 5 secondes.
// Comparez AVANT / APRÈS migration vers OnPush.

3. Inspect via le DOM

Sélectionnez un composant dans l'onglet Components d'Angular DevTools. La pastille indique sa stratégie (Default ou OnPush) et compte combien de fois il a été vérifié pendant l'enregistrement. Plus le nombre est élevé sur des composants sans changement réel, plus le gain potentiel est grand.

Étude de cas — table de 5 000 lignes

Sur un projet réel de back-office avec une table virtualisée de 5 000 lignes, chaque clic global déclenchait initialement un cycle de Change Detection de 180 ms (16 fps, perçu comme du jank). Après audit, trois actions ont suffi : migration des TableRowComponent vers OnPush, passage des Inputs en signal input(), et remplacement des this.rows.push() par this.rows = [...this.rows, row]. Le cycle est descendu à 9 ms — 20× plus rapide, sans toucher au virtual scroll ni au CSS. L'effort total : environ deux heures de refactor sur les 3 composants critiques.

La leçon : sur 90 % des applications Angular, le bottleneck n'est pas le DOM ni le réseau, mais le Change Detection sur des composants qui n'ont pas changé. OnPush + immuabilité résout ce problème, et le Profiler le prouve en chiffres.

Bonnes pratiques et anti-patterns

À faire

  • OnPush par défaut sur tout composant de présentation (cartes, items de liste, widgets).
  • Immuabilité partout — spread sur objets/tableaux, Map/Set reconstruits, ou Immer.
  • Async pipe systématique pour les Observables — pas de subscribe() manuel dans le composant.
  • Signal inputs pour les nouveaux composants — input() + computed().
  • trackBy / track sur les @for pour préserver les nœuds DOM lors des mises à jour.
  • Mesurer avant/après via DevTools Profiler — chiffres, pas intuition.

À éviter

  • Mutations directesthis.user.name = X, this.arr.push(x), this.map.set().
  • detectChanges() systématique — c'est un patch, pas une solution. Si vous l'appelez souvent, le problème est ailleurs.
  • Méthodes appelées depuis le template{{ getFullName() }} est rappelée à chaque cycle. Préférez un computed ou une propriété calculée une fois.
  • OnPush sans immuabilité — pire combinaison possible : l'UI ne se met plus à jour et les bugs sont silencieux.
  • Setter @Input avec effets de bord lourds — préférez ngOnChanges ou un effect() avec signals.

Anti-pattern courant — le « ça ne se met pas à jour »

// ❌ Mauvais : modification interne — invisible pour OnPush
this.notifications.push(newNotif);
this.cdr.detectChanges(); // patch qui cache le vrai problème

// ✅ Bon : nouvelle référence — Angular détecte automatiquement
this.notifications = [...this.notifications, newNotif];

// ✅ Encore mieux : signal
this.notifications.update(arr => [...arr, newNotif]);

Plan de migration d'une app Default vers OnPush

Sur une codebase existante, ne basculez pas tout d'un coup — le risque d'introduire des bugs silencieux est trop élevé. Suivez plutôt cette progression en cinq étapes, validée par le Profiler à chaque palier.

  1. Audit — DevTools Profiler sur les écrans critiques (listes, tableaux, dashboards). Identifiez les 3-5 composants les plus coûteux par cycle.
  2. Composants feuilles d'abord — activez OnPush sur les composants présentationnels purs (cartes, badges, items). Risque quasi nul, gain immédiat.
  3. Audit des mutations — recherchez .push(, .splice(, obj.prop = dans le code. Remplacez par des opérations immuables ou des signals.
  4. Migration des Inputs vers input() — lancez ng generate @angular/core:signal-input-migration. Le schématic est conservateur et sûr.
  5. OnPush sur les containers — en dernier, étendez la stratégie aux composants conteneurs (pages, sections). Testez chaque écran en local avant merge.

Comptez environ une demi-journée pour les feuilles d'une feature classique, et deux à trois jours pour finaliser un grand écran (table, dashboard). Le ROI est immédiat dès la première étape — vous pouvez livrer en plusieurs PR sans tout casser.

Conclusion

ChangeDetectionStrategy.OnPush n'est pas une optimisation exotique : c'est la stratégie par défaut des applications Angular sérieuses, et la condition pour passer en mode zoneless demain. Couplée à l'immuabilité et aux Signals, elle divise le temps CPU de chaque cycle par 5 à 20 sur les écrans denses, et rend votre application prête pour Angular 21+ sans réécriture.

markForCheck(), detectChanges(), detach() et reattach() ne sont pas des outils du quotidien — ce sont les vis de réglage que vous utilisez ponctuellement, quand une lib tierce échappe à Angular ou qu'une mesure DOM doit être immédiate. Dans 95 % des cas, OnPush + Signals + async pipe suffisent à eux seuls.

Si vous deviez ne retenir qu'une seule règle pratique : travaillez en immuable, lisez vos états via Signals dans vos templates, et configurez tous vos composants présentationnels en OnPush. Vous obtiendrez sans effort supplémentaire un Change Detection minimaliste, une codebase compatible avec le mode zoneless, et une expérience utilisateur fluide même sur les listes les plus larges. Les concepts vus ici resteront pertinents bien au-delà d'Angular 21, car ils s'inscrivent dans la direction long terme du framework : moins de magie globale, plus de réactivité fine et explicite.

Récapitulatif des bonnes pratiques :
  • Adopter OnPush sur tout composant feuille (cartes, items, widgets)
  • Travailler en immuable — spread, Immer ou Signals — jamais de mutation in-place
  • Préférer input() et computed() aux @Input classiques
  • Utiliser async pipe systématiquement, jamais de subscribe() manuel dans le composant
  • Réserver markForCheck() aux callbacks externes non patchés par Zone
  • N'appeler detectChanges() que pour les mises à jour synchrones urgentes (capture, mesure DOM)
  • Toujours mesurer via DevTools Profiler avant et après — chiffres, pas intuition
  • Préparer la migration zoneless en éliminant les mutations et en s'appuyant sur les Signals

Partager