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.
@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);
}
}
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 |
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 ».
- 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
asyncpipe é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' };
}
}
@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 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
- Mettre à jour vers Angular 22 et lancer la suite de tests E2E
- Marquer en
Eagertout 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
Eageret 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
}
}
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 |
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)
);
}
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');
});
});
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 |
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(),
],
};
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.
- Mettre à jour avec
ng update @angular/core@22 @angular/cli@22 - Supprimer le
changeDetection: OnPushredondant 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
Eagercomme 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.