Front-end

- Angular 22 : OnPush par défaut, le guide pratique

Angular Angular-22 Onpush Change-Detection Changedetectionstrategy Eager Markforcheck Signals Performance Zoneless Migration Typescript Front-End Detectchanges
Angular 22 : OnPush par défaut, le guide pratique

Maitrisez OnPush devenu strategie par defaut en Angular 22 : declencheurs de detection, echappatoire Eager, pieges de migration, markForCheck et Signals avec exemples.

Le grand basculement d'Angular 22

Pendant des années, créer un composant Angular performant impliquait un geste rituel : ajouter changeDetection: ChangeDetectionStrategy.OnPush dans chaque @Component. Oublier ce réglage condamnait l'application à vérifier l'intégralité de l'arbre de composants à chaque clic, chaque setTimeout, chaque requête HTTP. Angular 22 met fin à ce rituel : OnPush devient la stratégie de détection de changement par défaut.

Ce n'est pas un détail cosmétique. C'est l'un des changements les plus structurants de la version, dans la continuité des Signals (Angular 16+), de la détection sans zone (zoneless, Angular 18+) et de la stabilisation du graphe réactif. Le message d'Angular est clair : la réactivité fine pilotée par Signals remplace la détection globale héritée de Zone.js. Faire de OnPush le défaut, c'est aligner le comportement par défaut du framework sur ses propres recommandations de performance.

Mais un changement de défaut est aussi un breaking change. Des composants qui « marchaient » uniquement parce qu'Angular vérifiait tout en permanence peuvent cesser de se rafraîchir. Cet article explique le mécanisme, ce qui change précisément, comment repérer et réparer les composants cassés, et comment utiliser l'échappatoire Eager pendant la transition.

En une phrase : en Angular 22, un composant ne déclenche le rendu que lorsqu'un Signal lu dans son template change, qu'un de ses @Input() reçoit une nouvelle référence, qu'un événement DOM se produit dans son template, ou qu'un async pipe émet — exactement comme OnPush le faisait, mais désormais sans rien écrire.

Rappel : Default vs OnPush

Pour comprendre l'impact du nouveau défaut, il faut distinguer les deux stratégies historiques. Avec la stratégie Default (aussi appelée CheckAlways), Angular re-vérifie un composant à chaque cycle de détection global, déclenché par n'importe quel événement intercepté par Zone.js : clic, timer, promesse résolue, requête XHR. Avec OnPush, Angular ne vérifie le composant que si une condition de « salissure » (dirty) précise est remplie.

Tableau comparatif des deux stratégies

Critère Default (CheckAlways) OnPush
Fréquence de vérification À chaque cycle global Seulement si la vue est « sale »
Coût CPU sur grand arbre Élevé (O(n) à chaque tick) Faible (sous-arbres ignorés)
Mutation d'objet sans nouvelle référence Détectée Ignorée (piège classique)
Signals lus dans le template Détectés Détectés (marquage auto)
Statut en Angular ≤ 21 Défaut Opt-in manuel
Statut en Angular 22 Eager (opt-in) Défaut

Le même composant, deux comportements

// counter.component.ts — comportement identique en v22 par défaut
// qu'avec un OnPush explicite en v21
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  // En Angular 21 il fallait écrire ceci pour être performant :
  // changeDetection: ChangeDetectionStrategy.OnPush,
  // En Angular 22, c'est déjà le comportement par défaut.
  template: `
    <button class="btn btn-primary" (click)="increment()">
      Compteur : {{ count() }}
    </button>
  `,
})
export class CounterComponent {
  // Signal : sa lecture dans le template marque la vue automatiquement
  count = signal(0);

  increment(): void {
    // .update() change la valeur du Signal -> la vue est marquée sale
    this.count.update(n => n + 1);
  }
}
Un composant écrit « proprement » avec des Signals se comporte de façon strictement identique en Default ou OnPush. C'est précisément pour cela qu'Angular peut basculer le défaut sans casser les applications déjà modernisées : si vous utilisez Signals et async pipe, vous ne verrez aucune différence — seulement un gain de performance.

Ce qui change concrètement en v22

Concrètement, tout composant déclaré sans propriété changeDetection est désormais traité comme OnPush. Vous n'avez plus rien à écrire pour bénéficier de la stratégie performante. À l'inverse, les composants qui dépendaient implicitement de CheckAlways doivent être identifiés.

Avant / Après — la déclaration de composant

// ❌ AVANT (Angular ≤ 21) — OnPush devait être explicite
import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-product-list',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush, // boilerplate répété partout
  template: '...',
})
export class ProductListComponent {}
// ✅ APRÈS (Angular 22) — OnPush implicite, zéro boilerplate
import { Component } from '@angular/core';

@Component({
  selector: 'app-product-list',
  standalone: true,
  // Plus besoin de changeDetection : OnPush est le défaut
  template: '...',
})
export class ProductListComponent {}

Les trois catégories de composants

Au moment de migrer, classez mentalement vos composants en trois familles. Cette grille de lecture accélère énormément l'audit.

Famille Caractéristique Action en v22
Déjà OnPush changeDetection: OnPush déjà présent Rien à faire — comportement inchangé
Moderne implicite Signals + async pipe, pas de mutation Rien à faire — devient OnPush sans effet visible
Legacy fragile Mutations, timers, callbacks impératifs Réparer (Signals/markForCheck) ou Eager
Bonne nouvelle : dans une application Angular maintenue depuis la v17, la grande majorité des composants tombe dans les deux premières familles. Le travail de migration se concentre sur une minorité de composants legacy fragiles — souvent ceux qui intègrent une librairie JavaScript tierce.

Ce qui rend une vue « sale » en OnPush

En OnPush, Angular ne re-rend un composant que si l'une de ces conditions est remplie. Connaître cette liste par cœur évite 90 % des bugs de « vue qui ne se rafraîchit pas ».

Les déclencheurs de détection en OnPush :
  • Un Signal lu dans le template change de valeur
  • Un @Input() reçoit une nouvelle référence (pas une mutation)
  • Un événement DOM se déclenche dans le template du composant ((click), (input)…)
  • Un async pipe émet une nouvelle valeur
  • On appelle explicitement ChangeDetectorRef.markForCheck()

Le piège n°1 : la mutation d'objet

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

@Component({
  selector: 'app-user-card',
  standalone: true,
  template: `<p>{{ user().name }} — {{ user().role }}</p>`,
})
export class UserCardComponent {
  user = input.required<{ name: string; role: string }>();
}
// parent.component.ts
export class ParentComponent {
  user = { name: 'Said', role: 'dev' };

  promote(): void {
    // ❌ MUTATION : même référence d'objet -> OnPush ignore le changement
    // La carte enfant n'affichera JAMAIS le nouveau rôle
    this.user.role = 'lead';
  }

  promoteCorrect(): void {
    // ✅ NOUVELLE RÉFÉRENCE : OnPush détecte le changement de @Input
    this.user = { ...this.user, role: 'lead' };
  }
}
Cette règle d'immutabilité existait déjà pour les composants OnPush en v21. Ce qui change en v22, c'est qu'elle s'applique désormais par défaut à tous vos composants. Si votre code mute des objets passés en @Input(), c'est le moment de passer à l'immutabilité — ou aux Signals, qui rendent le problème caduc.

La solution durable : passer l'état en Signal

// parent.component.ts — version Signals (recommandée en v22)
import { Component, signal } from '@angular/core';

export class ParentComponent {
  // Le state vit dans un Signal writable
  user = signal({ name: 'Said', role: 'dev' });

  promote(): void {
    // .update() produit une nouvelle référence ET notifie le graphe réactif
    this.user.update(u => ({ ...u, role: 'lead' }));
  }
}

L'échappatoire : ChangeDetectionStrategy.Eager

Angular 22 introduit une nouvelle valeur explicite, ChangeDetectionStrategy.Eager, qui restaure le comportement historique CheckAlways. C'est l'inverse de la démarche d'avant : hier on activait OnPush pour être performant, aujourd'hui on active Eager pour revenir en arrière de manière ciblée.

// legacy-widget.component.ts
// Composant qui intègre une vieille librairie jQuery mutant le DOM
import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-legacy-widget',
  standalone: true,
  // Filet de sécurité temporaire : on garde la vérification systématique
  changeDetection: ChangeDetectionStrategy.Eager,
  template: '...',
})
export class LegacyWidgetComponent {}
Eager est une béquille, pas une destination. Il permet de migrer une grosse application sans tout réécrire le jour J. Mais chaque composant Eager annule le gain de performance d'OnPush et conserve la dette technique. Traitez-les comme une liste TODO : convertissez-les progressivement vers Signals, puis retirez le Eager.

Stratégie de migration progressive

Plan de bascule sécurisé :
  • Mettre à jour vers Angular 22 et lancer la suite de tests E2E
  • Marquer en Eager tout composant qui régresse visuellement
  • Créer un ticket par composant Eager (dette à résorber)
  • Convertir l'état mutable vers des Signals, sprint après sprint
  • Retirer le Eager et valider que la vue reste correcte

Les pièges courants après migration

Trois familles de bugs reviennent systématiquement après le passage en v22. Les reconnaître permet de les corriger en quelques minutes.

Piège 1 — État modifié dans un timer

// clock.component.ts
import { Component, signal, OnInit } from '@angular/core';

@Component({
  selector: 'app-clock',
  standalone: true,
  template: `<span class="badge bg-dark">{{ time() }}</span>`,
})
export class ClockComponent implements OnInit {
  time = signal(new Date().toLocaleTimeString());

  ngOnInit(): void {
    // ✅ setInterval écrit dans un Signal -> la vue se met à jour
    // (même en zoneless, car c'est le Signal qui marque la vue)
    setInterval(() => {
      this.time.set(new Date().toLocaleTimeString());
    }, 1000);
  }
}
// ❌ Version cassée en v22 : propriété simple au lieu d'un Signal
export class BrokenClockComponent implements OnInit {
  time = new Date().toLocaleTimeString(); // propriété brute

  ngOnInit(): void {
    setInterval(() => {
      // La propriété change mais rien ne marque la vue : l'horloge fige
      this.time = new Date().toLocaleTimeString();
    }, 1000);
  }
}

Piège 2 — Callback d'une librairie tierce

// map.component.ts — intégration d'une carte (Leaflet, Mapbox…)
import { Component, signal, inject, ChangeDetectorRef } from '@angular/core';

@Component({
  selector: 'app-map',
  standalone: true,
  template: `<p>Zoom : {{ zoom() }}</p><div id="map"></div>`,
})
export class MapComponent {
  zoom = signal(10);
  private cdr = inject(ChangeDetectorRef);

  initMap(mapLib: any): void {
    mapLib.on('zoomend', (e: { level: number }) => {
      // Le callback vient d'un code hors Angular.
      // En écrivant dans un Signal, la vue est marquée automatiquement :
      this.zoom.set(e.level);

      // Si l'on devait modifier une propriété brute, il faudrait :
      // this.cdr.markForCheck();
    });
  }
}

Piège 3 — Référence d'@Input non renouvelée

// ❌ Le parent pousse dans le même tableau
export class ListParentComponent {
  items: string[] = ['a', 'b'];

  add(): void {
    this.items.push('c');  // mutation : l'enfant OnPush ne voit rien
  }

  addCorrect(): void {
    this.items = [...this.items, 'c'];  // nouvelle référence : OK
  }
}
Point commun des trois pièges : un changement d'état hors du graphe réactif. La parade universelle en v22 est de faire vivre l'état dans des Signals. Le markForCheck() reste le plan B pour le code que vous ne pouvez pas signaliser immédiatement.

markForCheck() et Signals : qui fait quoi

Avec OnPush par défaut, deux mécanismes coexistent pour notifier Angular qu'une vue doit être re-rendue. Comprendre lequel utiliser évite à la fois les bugs et la sur-ingénierie.

Signals — le mécanisme par défaut

// notifications.component.ts
import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-notifications',
  standalone: true,
  template: `
    <span class="badge bg-danger">{{ unreadCount() }}</span>
    @for (n of notifications(); track n.id) {
      <div class="alert alert-info">{{ n.text }}</div>
    }
  `,
})
export class NotificationsComponent {
  notifications = signal<{ id: number; text: string; read: boolean }[]>([]);

  // computed() est lu dans le template : il marque la vue automatiquement
  unreadCount = computed(() => this.notifications().filter(n => !n.read).length);

  push(text: string): void {
    // Nouvelle référence + notification du graphe : rendu garanti
    this.notifications.update(list => [
      ...list,
      { id: Date.now(), text, read: false },
    ]);
  }
}

markForCheck() — pour le code impératif résiduel

// price-ticker.component.ts — flux WebSocket brut, sans Signal
import { Component, inject, ChangeDetectorRef, OnInit, OnDestroy } from '@angular/core';

@Component({
  selector: 'app-price-ticker',
  standalone: true,
  template: `<strong>{{ price }} €</strong>`,
})
export class PriceTickerComponent implements OnInit, OnDestroy {
  price = 0;                  // propriété brute (legacy)
  private socket?: WebSocket;
  private cdr = inject(ChangeDetectorRef);

  ngOnInit(): void {
    this.socket = new WebSocket('wss://api.example.com/prices');
    this.socket.onmessage = (event) => {
      this.price = JSON.parse(event.data).value;
      // Changement hors graphe réactif : on marque la vue manuellement
      this.cdr.markForCheck();
    };
  }

  ngOnDestroy(): void {
    this.socket?.close();
  }
}

detectChanges() vs markForCheck()

Méthode Effet Quand l'utiliser
markForCheck() Marque le composant et ses ancêtres « à vérifier » au prochain cycle 99 % des cas en OnPush
detectChanges() Force un cycle de détection immédiat et synchrone Cas rares : avant une capture/mesure DOM synchrone
Signal lu dans le template Marque la vue automatiquement à chaque changement Le défaut recommandé en v22
Règle de décision : si vous pouvez exprimer l'état avec un Signal, faites-le et oubliez markForCheck(). Réservez markForCheck() au code impératif que vous ne contrôlez pas entièrement (librairies, WebSocket bruts). N'utilisez detectChanges() que si vous avez besoin du DOM mis à jour dans la même tâche synchrone.

Migrer son application étape par étape

La mise à jour vers Angular 22 passe par l'outillage officiel. La commande ng update applique les schématics et signale les points d'attention.

# Mettre à jour le framework et le CLI vers la v22
ng update @angular/core@22 @angular/cli@22

# Lancer le build pour repérer les erreurs de typage liées à la v22
ng build

# Lancer la suite de tests pour détecter les régressions de rendu
ng test

Détecter les composants à risque par recherche

Avant même de lancer l'application, une recherche statique repère les patterns dangereux. Les mutations sur des entrées et l'usage de timers sont les premiers suspects.

# Repérer les mutations de tableaux (push, splice) — candidats au bug OnPush
grep -rn "\.push(\|\.splice(\|\.pop(" src/app --include="*.ts"

# Repérer les composants encore en CheckAlways implicite (sans changeDetection)
# puis vérifier manuellement ceux qui manipulent du DOM ou des timers
grep -rLn "changeDetection" src/app --include="*.component.ts"

# Repérer les timers susceptibles d'écrire dans des propriétés brutes
grep -rn "setInterval\|setTimeout" src/app --include="*.ts"

Convertir un composant legacy — exemple complet

// ❌ AVANT — dépendait de CheckAlways implicite (cassé en v22)
import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-cart-summary',
  standalone: true,
  template: `
    <p>Articles : {{ lines.length }}</p>
    <p>Total : {{ computeTotal() }} €</p>
  `,
})
export class CartSummaryComponent {
  @Input() lines: { qty: number; price: number }[] = [];

  // Recalculé à chaque cycle global — coûteux et dépend de CheckAlways
  computeTotal(): number {
    return this.lines.reduce((s, l) => s + l.qty * l.price, 0);
  }
}
// ✅ APRÈS — Signals + computed, parfaitement OnPush-compatible
import { Component, input, computed } from '@angular/core';

@Component({
  selector: 'app-cart-summary',
  standalone: true,
  template: `
    <p>Articles : {{ count() }}</p>
    <p>Total : {{ total() }} €</p>
  `,
})
export class CartSummaryComponent {
  // input() : le @Input devient un Signal, le parent renouvelle la référence
  lines = input.required<{ qty: number; price: number }[]>();

  // computed() : recalculé seulement si lines() change, et marque la vue
  count = computed(() => this.lines().length);
  total = computed(() =>
    this.lines().reduce((s, l) => s + l.qty * l.price, 0)
  );
}
Remplacer un appel de méthode (computeTotal()) par un computed() est un double gain : compatibilité OnPush et performance, car le calcul est mémoïsé au lieu d'être ré-exécuté à chaque détection.

Tester un composant OnPush

Les tests unitaires sont sensibles au changement de défaut. En OnPush, modifier une propriété puis appeler fixture.detectChanges() ne suffit pas toujours : il faut que la vue ait été marquée. Avec les Signals, c'est transparent ; avec des entrées classiques, il faut renouveler la référence.

// cart-summary.component.spec.ts
import { TestBed } from '@angular/core/testing';
import { CartSummaryComponent } from './cart-summary.component';

describe('CartSummaryComponent (OnPush)', () => {
  it('affiche le total après changement de lines', () => {
    const fixture = TestBed.createComponent(CartSummaryComponent);

    // setInput() renouvelle la référence du signal input -> vue marquée
    fixture.componentRef.setInput('lines', [
      { qty: 2, price: 10 },
      { qty: 1, price: 5 },
    ]);

    // detectChanges() applique le rendu de la vue désormais marquée sale
    fixture.detectChanges();

    const text = fixture.nativeElement.textContent as string;
    expect(text).toContain('Total : 25');
    expect(text).toContain('Articles : 2');
  });
});
Bonne pratique de test en v22 : utilisez systématiquement fixture.componentRef.setInput() plutôt que d'assigner directement fixture.componentInstance.lines = .... setInput() simule le binding réel d'un parent et marque correctement la vue OnPush — ce que l'assignation directe ne fait pas.

Tester un état mis à jour de façon asynchrone

// counter.component.spec.ts
it('incrémente le compteur au clic', () => {
  const fixture = TestBed.createComponent(CounterComponent);
  fixture.detectChanges();

  const button: HTMLButtonElement = fixture.nativeElement.querySelector('button');
  button.click();          // événement DOM -> marque la vue (OnPush)
  fixture.detectChanges(); // applique le rendu

  expect(button.textContent).toContain('Compteur : 1');
});

Performance : mesurer le gain réel

Le bénéfice de OnPush par défaut se mesure au nombre de composants vérifiés par cycle de détection. Sur une application réelle (tableau de bord avec listes, graphiques et widgets), passer de Default à OnPush divise typiquement par 5 à 20 le nombre de vérifications par interaction utilisateur.

Profiler avec Angular DevTools

// Activer le profiling de détection de changement en développement
// dans main.ts (à retirer en production)
import { enableProfiling } from '@angular/core';

if (!environmentIsProd) {
  // Affiche dans l'onglet « Profiler » d'Angular DevTools
  // le temps passé en détection par composant
  enableProfiling();
}

Ordre de grandeur observé

Scénario (dashboard ~400 composants) Default (v21) OnPush défaut (v22)
Composants vérifiés par clic isolé ~400 ~15 à 40
Temps de détection moyen / tick 8–14 ms 1–3 ms
Saccades sur frappe clavier rapide Perceptibles Quasi nulles
Ces chiffres sont des ordres de grandeur indicatifs, pas une garantie : le gain dépend de la profondeur de l'arbre et du nombre de bindings. Mesurez toujours sur votre application avec Angular DevTools avant et après, plutôt que de vous fier à des moyennes génériques.

Combiner OnPush et zoneless pour le maximum

// app.config.ts — détection sans Zone.js, alignée avec OnPush par défaut
import { ApplicationConfig, provideZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    // Supprime Zone.js : la détection est pilotée 100 % par les Signals
    // C'est la combinaison la plus performante en Angular 22
    provideZonelessChangeDetection(),
  ],
};
Le combo gagnant en v22 : OnPush par défaut + mode zoneless + état en Signals. À ce stade, Angular ne déclenche un rendu que lorsqu'une donnée réellement affichée change. C'est le modèle de réactivité fine vers lequel le framework converge depuis la v16.

Conclusion

Faire de OnPush la stratégie par défaut est l'aboutissement logique de la trajectoire d'Angular : Signals, zoneless, graphe réactif. Pour les projets modernes — ceux qui utilisent Signals et async pipe — la migration vers Angular 22 est transparente et apporte un gain de performance immédiat, sans une ligne de code à écrire. Le boilerplate changeDetection: ChangeDetectionStrategy.OnPush répété dans chaque composant appartient désormais au passé.

Pour les bases de code legacy, le travail consiste à identifier la minorité de composants fragiles — mutations d'objets, timers, callbacks de librairies tierces — et à les convertir aux Signals. L'échappatoire ChangeDetectionStrategy.Eager offre un filet de sécurité temporaire pour migrer sans tout réécrire d'un bloc, mais elle doit rester transitoire : chaque Eager est une dette de performance à résorber.

Récapitulatif des actions clés :
  • Mettre à jour avec ng update @angular/core@22 @angular/cli@22
  • Supprimer le changeDetection: OnPush redondant des composants modernes
  • Auditer les mutations d'@Input(), les timers et les callbacks tiers
  • Faire vivre l'état dans des Signals — c'est la solution durable
  • Garder markForCheck() pour le code impératif résiduel
  • Utiliser Eager comme béquille temporaire, jamais comme cible
  • Tester avec fixture.componentRef.setInput()
  • Mesurer le gain avec Angular DevTools et envisager le mode zoneless

En traitant OnPush comme le nouveau socle de votre architecture plutôt que comme une contrainte, vous obtenez des applications Angular 22 plus rapides, plus prévisibles, et parfaitement alignées avec l'avenir réactif du framework.

Partager