Change Detection Angular — Default, OnPush, Signals, Zoneless
TL;DR — La Change Detection (CD) est le mécanisme qui synchronise le DOM avec l'état du composant. Avec Zone.js, Angular intercepte tous les événements async (timers, XHR, events) et déclenche une CD globale. Avec
OnPush, le composant n'est revérifié que si une@Inputchange par référence, un événement DOM survient dans son template, un Observable bound viaasyncémet, ou un signal lu dans son template change. En Zoneless (Angular 20 GA), Zone.js disparaît : la CD est déclenchée explicitement par les signals, les events, le HttpClient signal-aware etmarkForCheck. Conclusion : écrivez du code "zoneless-ready" dès aujourd'hui —OnPushpartout, Signals pour le state, RxJS viaasync/toSignal.
🧠 Mental model — ASCII + analogie
Change Detection = l'arroseur automatique du jardin. Angular parcourt l'arbre des composants depuis la racine, demande à chaque nœud "est-ce que ton template doit être re-rendu ?" et compare les expressions du template avec leur valeur précédente. Si différent, il met à jour le DOM.
Avec Zone.js, n'importe quel événement async (clic, setTimeout, requête HTTP) déclenche un tick global qui arrose tout l'arbre.
Avec OnPush, l'arroseur saute les sous-arbres qui n'ont pas levé la main.
Avec Zoneless, il n'y a plus d'arroseur automatique : c'est vous (ou les signals) qui demandez explicitement à arroser.
Default CD (Zone.js) OnPush Zoneless
────────────────── ────── ────────
Any async event → tick Vérifié SI : No global tick
┌──A──┐ - input ref change Trigger explicite :
│ │ - DOM event dans son TPL - signal change
▼ ▼ - async pipe emits - DOM event handler
B C - signal lu change - HttpClient (signal-aware)
│ │ - markForCheck() - markForCheck()
▼ ▼ - detectChanges()
D E
Sinon : SKIP entier
Tout est revérifié du sous-arbre
à chaque tickAnalogie zoneless — vous passez d'un système de monitoring qui ping tout, tout le temps à un système événementiel où les composants annoncent eux-mêmes qu'ils ont changé. Moins de bruit, plus de précision, et beaucoup moins de CPU.
🛠️ Code minimal (ts + html)
Default vs OnPush
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
@Component({
selector: 'app-default',
template: `<button (click)="counter = counter + 1">{{ counter }}</button>`,
})
export class DefaultCmp {
counter = 0; // mutation directe, déclenche CD via Zone.js
}
@Component({
selector: 'app-onpush',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<button (click)="inc()">{{ counter() }}</button>`,
})
export class OnPushCmp {
counter = signal(0); // signal -> auto markForCheck quand lu
inc() { this.counter.update(c => c + 1); }
}Forcer la CD manuellement
import { Component, ChangeDetectorRef, inject } from '@angular/core';
@Component({ /* OnPush */ })
export class ManualCmp {
private cdr = inject(ChangeDetectorRef);
data: any;
loadFromCallback() {
legacyApi.onData((d) => {
this.data = d;
this.cdr.markForCheck(); // marquer dirty, recheck au prochain tick
// this.cdr.detectChanges(); // recheck IMMÉDIAT (sync) — coûteux
});
}
}markForCheck(): marque le composant et tous ses ancêtresOnPushcomme "à revérifier au prochain tick". À privilégier.detectChanges(): exécute la CD immédiatement sur ce composant et ses descendants. Synchrone, plus coûteux, utile pour des cas pointus.detach()/reattach(): sort un composant de l'arbre de CD entièrement.
detach() / reattach() — l'« île de CD manuelle »
Technique de staff pour le cas extrême : un composant qui affiche un flux à très haut débit (ticker boursier à 1000 msg/s, console de logs, viewer télémétrie) où vous voulez un contrôle total du rythme de rendu, sans dépendre du scheduler. Vous détachez le composant de l'arbre de CD, puis vous rappelez detectChanges() vous-même à la cadence voulue (souvent dans un rAF throttlé) :
@Component({
selector: 'app-high-freq-ticker',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<pre>{{ snapshot }}</pre>`,
})
export class HighFreqTicker implements OnInit, OnDestroy {
private readonly cdr = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
protected snapshot = '';
private rafId = 0;
private latest = '';
ngOnInit(): void {
this.cdr.detach(); // sort de l'arbre : plus AUCUN tick automatique ici
feed.subscribe((msg) => { this.latest = msg; }); // cheap : pas de CD
const render = () => {
if (this.snapshot !== this.latest) {
this.snapshot = this.latest;
this.cdr.detectChanges(); // CD locale, à NOTRE rythme (1×/frame)
}
this.rafId = requestAnimationFrame(render);
};
this.rafId = requestAnimationFrame(render);
this.destroyRef.onDestroy(() => cancelAnimationFrame(this.rafId));
}
ngOnDestroy(): void {} // requis pour OnDestroy lifecycle
}Mental model staff : detach() transforme un sous-arbre en boîte noire que le scheduler ignore. C'est puissant mais dangereux — vous devenez responsable de chaque rafraîchissement, y compris des @Input/signals qui changent. À réserver aux hot paths mesurés. En 2026, le pattern « buffer + signal coalescé via rAF » (voir section LLM) couvre 95 % des cas sans sortir de l'arbre de CD — préférez-le. detach() reste pour les 5 % où même le scheduler signal est un overhead mesurable.
Zone.js — ce qui se passe sous le capot
// main.ts traditionnel
import { bootstrapApplication } from '@angular/platform-browser';
import 'zone.js'; // polyfill chargé avant tout
bootstrapApplication(AppComponent);Zone.js monkey-patche setTimeout, setInterval, Promise.then, XMLHttpRequest, fetch, addEventListener, etc. Chaque appel est wrappé pour exécuter Angular's tick() à la fin du microtask. C'est pratique (l'appli "marche" sans rien expliquer), mais c'est aussi du CPU constant et une boîte noire pour le debugging.
Zoneless — Angular 18 (preview), 19 (expérimental), 20 (stable)
// main.ts zoneless
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
],
});Et dans angular.json ou tsconfig, supprimez Zone.js des polyfills :
"polyfills": [] // pas de "zone.js"Historique :
- Angular 18 :
provideExperimentalZonelessChangeDetection. - Angular 19 : toujours sous le nom expérimental, mais largement testé.
- Angular 20 :
provideZonelessChangeDetectionstable, la doc le recommande pour les nouveaux projets.
🎯 Patterns courants
1. OnPush + signals — le combo canonique
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h1>{{ user().name }}</h1>
<p>Total : {{ total() }}</p>
<button (click)="refresh()">Refresh</button>
`,
})
export class UserCard {
user = signal({ name: 'Alice', score: 10 });
total = computed(() => this.user().score * 2);
refresh() { this.user.update(u => ({ ...u, score: u.score + 1 })); }
}Tout est OnPush, aucun markForCheck, aucun detectChanges. Le runtime Angular voit qu'un signal lu dans le template a changé, marque le composant dirty et re-rend.
2. OnPush + async pipe pour les Observables
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (user$ | async; as user) {
<h1>{{ user.name }}</h1>
} @else {
<p>Loading…</p>
}
`,
})
export class UserAsync {
user$ = inject(HttpClient).get<User>('/me');
}L'async pipe appelle markForCheck() à chaque émission. Pas de subscribe manuel, pas de fuite, OnPush qui réagit. Pattern le plus sûr pour mixer Rx et OnPush.
3. Callback externe sans Zone — markForCheck explicite
@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class WebGLOverlay {
private cdr = inject(ChangeDetectorRef);
fps = 0;
constructor() {
// En zoneless, ce setTimeout ne déclenche AUCUNE CD
const loop = () => {
this.fps = computeFps();
this.cdr.markForCheck(); // obligatoire en zoneless
requestAnimationFrame(loop);
};
loop();
}
}4. Coalescing — un seul tick pour plusieurs events
Angular coalesce les changes : si 5 signals changent dans le même microtask, la CD ne tourne qu'une seule fois (au prochain microtask boundary). Vous n'avez rien à faire — le scheduler s'en charge.
5. Écrire du code zoneless-ready (sans encore migrer)
Même si vous gardez Zone.js, ces règles vous prépareront :
OnPushpar défaut sur tous les composants nouveaux.- Signals pour le state synchrone,
asyncpipe pour les Observables. - Pas de
setTimeoutqui assume une CD automatique — wrappez dansmarkForCheck. - Pas de mutation directe d'arrays / objects sans
update(a => [...a, x]).
🔄 Versions — Angular 16 → 20
| Version | Change Detection |
|---|---|
| 16 | Signals preview. Pas encore d'auto-marking via signals dans OnPush. |
| 17 | Signals stable. Auto-marking : un signal lu dans un template OnPush marque le composant dirty automatiquement. |
| 18 | provideExperimentalZonelessChangeDetection. Les output() (signal) bypassent Zone. afterRender / afterNextRender. Hybrid scheduling. |
| 19 | Zoneless toujours expérimental mais batteries-included. Resource API stable. |
| 20 | Zoneless GA via provideZonelessChangeDetection. Zone.js officiellement déprécié pour les nouveaux projets. CLI génère du zoneless par défaut. |
Bench rapide (indicatif, à mesurer chez vous)
Sur une app moyenne (200 composants, 10K nodes) :
| Config | TTI | CPU au repos | CD/sec en interaction |
|---|---|---|---|
| Default + Zone.js | 100% | 100% | 100% |
| OnPush + Zone.js | ~95% | ~70% | ~40% |
| OnPush + Signals + Zone.js | ~90% | ~50% | ~25% |
| OnPush + Signals + Zoneless | ~75% | ~20% | ~15% |
Le gros gain n'est pas le TTI mais le CPU au repos (idle), critique sur mobile et embarqué.
⚠️ Pitfalls — 10 erreurs qui mordent
Mutation directe d'array/object avec OnPush + non-signal —
this.items.push(x)ne change pas la référence, l'@Inputn'est pas détecté comme changé. Utilisezthis.items = [...this.items, x]ou un signal.ExpressionChangedAfterItHasBeenCheckedError— vous modifiez une valeur lue dans le template après la CD du parent. Solution : utilisezafterNextRenderou différez avecqueueMicrotask, ou repensez l'architecture (souvent un signal lu dans le template parent qui dépend d'un signal du child).Setter d'
@Inputavec side effect lourd — un setter qui fait du calcul à chaque CD = lag. Préférezeffect()sur un signal input.detectChanges()à tout-va — c'est synchrone et coûteux. 95% du temps,markForCheck()suffit.detectChangesest pour les cas où vous avez besoin d'un état DOM mis à jour immédiatement (avant ungetBoundingClientRect).Oublier de débrancher Zone.js après
provideZonelessChangeDetection— vous gardez Zone.js dans les polyfills, vous payez les deux coûts. Vérifiezangular.json.3rd-party lib qui ne déclenche pas la CD en zoneless — une lib qui utilise
setTimeoutbrut continue de marcher fonctionnellement, mais Angular ne re-rend pas. Solution : wrapper avecngZone.run(legacy) oumarkForCheck()après le callback.OnPush+ Observable subscribé manuellement sansmarkForCheck—subscribe(x => this.value = x)ne re-rend pas. Solutions :asyncpipe, outoSignal, outhis.cdr.markForCheck()dans le subscribe.Signals modifiés en dehors de l'injection context (workers, etc.) — fonctionnel mais les effects ne s'auto-cleanup pas. Pensez
takeUntilDestroyed.afterNextRenderutilisé pour du business logic —afterRenderetafterNextRendersont pour la lecture/écriture DOM safe (mesures, scroll, focus, libs DOM externes), pas pour calculer du state. Sinon vous re-déclenchez la CD en boucle.Confusion entre
tick,markForCheck,detectChanges,applicationRef.tick—tick()est global (toute l'app),detectChanges()est local sync,markForCheck()est local async. En zoneless, vous appeleztick()oumarkForCheck()explicitement quand vous interfacez avec du code non-réactif.
🧪 Testing — fakeAsync, TestBed.runInInjectionContext, debug avec Angular DevTools
fakeAsync + tick (pour le code qui dépend de timers/Promises)
fakeAsync virtualise l'horloge (timers, microtasks). tick(ms) avance le temps virtuel, flush() vide toute la file. Le test ci-dessous ne contient aucun async — il n'a donc pas besoin de fakeAsync ; il sert juste à montrer le cycle detectChanges → assert. On garde un tick() réel seulement quand le code teste un setTimeout/interval.
import { fakeAsync, tick, TestBed } from '@angular/core/testing';
// Cas SANS async : pas de fakeAsync nécessaire — détection synchrone.
it('CD updates after signal change', () => {
const fixture = TestBed.createComponent(OnPushCmp);
fixture.detectChanges();
const btn = fixture.nativeElement.querySelector('button');
expect(btn.textContent).toContain('0');
fixture.componentInstance.inc(); // signal.update()
fixture.detectChanges();
expect(btn.textContent).toContain('1');
});
// Cas AVEC async : ici fakeAsync + tick sont indispensables.
it('debounced search updates the view', fakeAsync(() => {
const fixture = TestBed.createComponent(SearchCmp);
fixture.detectChanges();
fixture.componentInstance.type('ang');
tick(300); // avance l'horloge virtuelle de 300 ms (debounce)
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('angular');
flush(); // au besoin : vide les timers restants pour éviter l'erreur "pending timers"
}));Tester les effects — TestBed.tick() (Angular 20)
TestBed.flushEffects() a été renommé TestBed.tick() en Angular 20 (l'ancien nom est déprécié). TestBed.tick() exécute un cycle de CD complet, ce qui flushe aussi les effects — c'est l'API à utiliser pour les nouveaux tests, en zoneless comme en Zone.js.
import { TestBed } from '@angular/core/testing';
import { signal, effect } from '@angular/core';
it('effect runs', () => {
TestBed.runInInjectionContext(() => {
const s = signal(0);
let captured = -1;
effect(() => (captured = s()));
TestBed.tick(); // Angular 20+ (remplace TestBed.flushEffects())
expect(captured).toBe(0);
s.set(5);
TestBed.tick();
expect(captured).toBe(5);
});
});⚠️ Piège : un
effect()ne s'exécute pas de façon synchrone aprèss.set(). Il est planifié pour le prochain cycle. SansTestBed.tick()(oufixture.detectChanges()), votre assertion lit l'ancienne valeur et le test passe à tort.
Tester en mode zoneless
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
it('no zone, manual tick', async () => {
const fixture = TestBed.createComponent(MyCmp);
await fixture.whenStable();
// Pas de zone.js, pas de tick automatique sauf via signal/event
});Angular DevTools — Profiler
Ouvrez Angular DevTools (extension Chrome/Firefox), onglet Profiler, cliquez Start Recording, interagissez, Stop. Vous voyez :
- Chaque cycle de CD avec sa durée.
- Quels composants ont été re-checkés.
- Le flame graph par composant : vous repérez le composant qui mange 80% du temps.
Astuce : sur un projet "Default everywhere", chaque clic provoque 50 vérifications. Sur OnPush + signals, 2-3. La différence saute aux yeux.
🎬 Cas d'usage concrets
Scénario 1 — Dashboard banque privée, 60 fps avec OnPush + signals
Un dashboard trader affiche en continu ~300 lignes de positions, avec une mise à jour de prix toutes les 200 ms via WebSocket. Sur Default change detection, chaque message déclenche une vérification de tout l'arbre (header, sidebar, menu...) — Chrome DevTools mesurait 22 ms par cycle de CD, soit ~45 fps avec saccades visibles.
Migration : tous les composants passent en ChangeDetection.OnPush, et l'état des positions devient un signal mis à jour par le service WebSocket (positions.set(...)). Le grid utilise @for ... track position.id pour ne pas recréer les DOM nodes. Le currency pipe est déjà pur, mais l'équipe remplace certains pipes custom impurs par des computed. Résultat : la CD coûte 1,8 ms par tick, soit largement sous 16 ms même sur Macbook 2018. Chrome FPS meter confirme 60 fps stable.
Détail subtil : le trader-clock qui affichait new Date() sans entrée signal restait bloqué après migration. La correction : now = toSignal(interval(1000).pipe(map(() => new Date()))). Sans signal qui « notifie » le composant, OnPush ne le rafraîchit jamais.
Scénario 2 — E-commerce, listing produits 1000+ items
Un site e-commerce de pièces auto a une catégorie « accessoires » avec 1 200 produits affichés dans une grille virtualisée. Le scroll devait être fluide, mais l'app était en Default avec des *ngFor sans trackBy. À chaque update d'un filtre, l'app pédalait 2-3 secondes.
L'équipe : (1) bascule tous les composants en OnPush, (2) utilise @for ... track product.id, (3) extrait les calculs de prix dérivés dans un computed au lieu de méthodes get dans le template (les getters s'exécutaient à chaque CD), (4) ajoute @defer (on viewport) autour des cartes hors écran. Le temps de rendu initial passe de 2,8 s à 480 ms, et le scroll devient fluide.
Surprise : un pipe custom discountedPrice était impur (pure: false) parce que le dev voulait qu'il se recalcule quand la promo changeait. Une fois remplacé par un computed à la source, le pipe pur (par défaut) suffit, et CD est 3× moins coûteux.
Scénario 3 — SaaS RH, analytics live avec OnPush
Une plateforme RH affiche en temps réel les KPIs du recrutement (candidats en pipeline, taux de conversion, temps moyen par étape). Les données s'actualisent toutes les 5 secondes via polling HTTP. Au début, l'app est entièrement en Default. Sur les tableaux d'analytics complexes (3-4 graphes simultanés), chaque tick provoquait un CD complet et des saccades visibles dans les graphes Chart.js.
Migration OnPush : les composants graphes reçoivent leur data en input typée Signal<Series[]>, ce qui suffit à déclencher la re-vérification quand le signal change (et seulement alors). Les autres composants (sidebar, header, profil utilisateur) sont stables et ne participent plus au cycle. Mesure DevTools : la CD est 6× plus rapide sur le tick polling.
Apprentissage transverse : markForCheck() n'est presque jamais nécessaire quand on travaille en signaux. Si on en a besoin, c'est souvent le symptôme d'un état non-signal qu'il faudrait convertir.
🛠️ Exemple end-to-end
Use case : grille de positions trading. OnPush partout, état signal, computed dérivés, @for trackBy, et un test qui vérifie que la CD ne marque pas trop de composants.
// position.types.ts
export interface Position {
symbol: string;
qty: number;
lastPriceCents: number;
changePct: number;
}// position.store.ts
import { Injectable, computed, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class PositionStore {
private readonly _byId = signal<ReadonlyMap<string, Position>>(new Map());
readonly list = computed(() => [...this._byId().values()]);
readonly totalCents = computed(() =>
[...this._byId().values()].reduce((acc, p) => acc + p.qty * p.lastPriceCents, 0),
);
readonly winners = computed(() => this.list().filter((p) => p.changePct > 0).length);
readonly losers = computed(() => this.list().filter((p) => p.changePct < 0).length);
applyTick(update: Position): void {
this._byId.update((map) => {
const next = new Map(map);
next.set(update.symbol, update);
return next;
});
}
bulkSet(positions: Position[]): void {
this._byId.set(new Map(positions.map((p) => [p.symbol, p])));
}
}// position-row.component.ts
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { CurrencyPipe, DecimalPipe } from '@angular/common';
import { Position } from './position.types';
@Component({
selector: 'app-position-row',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CurrencyPipe, DecimalPipe],
template: `
<td>{{ position().symbol }}</td>
<td>{{ position().qty }}</td>
<td>{{ position().lastPriceCents / 100 | currency: 'EUR' }}</td>
<td [class.up]="position().changePct > 0" [class.down]="position().changePct < 0">
{{ position().changePct | number: '1.2-2' }}%
</td>
`,
host: { '[attr.data-symbol]': 'position().symbol' },
})
export class PositionRowComponent {
readonly position = input.required<Position>();
}// positions-grid.component.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { PositionStore } from './position.store';
import { PositionRowComponent } from './position-row.component';
@Component({
selector: 'app-positions-grid',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [PositionRowComponent, CurrencyPipe],
template: `
<header>
<strong>Total : {{ store.totalCents() / 100 | currency: 'EUR' }}</strong>
<span>↑ {{ store.winners() }}</span>
<span>↓ {{ store.losers() }}</span>
</header>
<table>
<tbody>
@for (p of store.list(); track p.symbol) {
<tr>
<app-position-row [position]="p" />
</tr>
}
</tbody>
</table>
`,
})
export class PositionsGridComponent {
protected readonly store = inject(PositionStore);
}// positions-grid.spec.ts (vérifie que seul le row impacté re-render)
import { TestBed } from '@angular/core/testing';
import { PositionStore } from './position.store';
import { PositionsGridComponent } from './positions-grid.component';
it('updates only the affected row on tick', () => {
const fixture = TestBed.configureTestingModule({
imports: [PositionsGridComponent],
}).createComponent(PositionsGridComponent);
const store = TestBed.inject(PositionStore);
store.bulkSet([
{ symbol: 'AAA', qty: 10, lastPriceCents: 1000, changePct: 0 },
{ symbol: 'BBB', qty: 5, lastPriceCents: 2000, changePct: 0 },
]);
fixture.detectChanges();
const rowAAA = fixture.nativeElement.querySelector('[data-symbol="AAA"]');
const rowBBB = fixture.nativeElement.querySelector('[data-symbol="BBB"]');
store.applyTick({ symbol: 'AAA', qty: 10, lastPriceCents: 1050, changePct: 5 });
fixture.detectChanges();
// L'élément DOM AAA est toujours le même (track by symbol) — pas de re-création
expect(fixture.nativeElement.querySelector('[data-symbol="AAA"]')).toBe(rowAAA);
expect(fixture.nativeElement.querySelector('[data-symbol="BBB"]')).toBe(rowBBB);
expect(rowAAA.textContent).toContain('5.00%');
});Ce pattern (OnPush + signals + @for track + input typés) est la base d'une app fluide à 60 fps même sous flux temps réel intense. La CD ne touche que les composants dont les signaux d'entrée ont changé.
🔁 Quand utiliser / éviter
Use OnPush partout — sauf besoin légitime de Default (rare : intégration legacy 3rd-party qui mute des objets en place sans notif).
Use Default temporairement quand :
- Vous migrez un vieux projet et n'avez pas encore migré tous les services à signals/Rx.
- Vous testez du code expérimental sans vouloir polluer la logique CD.
Allez zoneless quand :
- Nouveau projet en Angular 19+.
- Vous avez maîtrisé OnPush + signals/async pipe partout.
- Vous voulez réduire le bundle (~50KB de Zone.js) et le CPU idle.
Restez avec Zone.js quand :
- Vous dépendez de libs 3rd-party qui ne sont pas zoneless-ready (vieux pickers, vieux charts).
- Votre équipe n'a pas encore le réflexe
markForCheck.
🧠 Approfondissement — le scheduler de CD interne
Depuis Angular 17, Angular utilise un scheduler qui regroupe les notifications de "dirty" et exécute un seul tick au prochain microtask boundary :
signal.set(x) │
signal.set(y) │ même microtask
markForCheck() │
inputChange via signal │
▼
┌────────────────────┐
│ ChangeDetection │
│ scheduler │
│ │
│ - dedup composants │
│ - 1 tick global │
│ - traverse OnPush │
│ dirty path │
└────────────────────┘Conséquence : faire 10 set() synchrones = 1 tick (pas 10). Cela rend les batched updates triviaux.
Le "dirty path" en OnPush
Quand un composant OnPush devient dirty (signal change, event, async), Angular marque tous ses ancêtres comme "doit être traversé" (mais pas vérifié au sens template). C'est le dirty path : une liste linéaire de la racine au composant dirty. Lors du tick suivant, Angular traverse uniquement ces nœuds, skip les autres sous-arbres.
Root ──── A ──── B ──── C (dirty)
│ │
│ └──── D (skip)
│
└──────── E ──── F (skip)
└─── G (skip)Si seul C est dirty, Angular ne visite que Root → A → B → C. D, E, F, G sont skippés. C'est le secret de la perf en OnPush.
🔬 Comment un signal déclenche exactement la CD (modèle staff)
Comprendre ce mécanisme fait la différence entre « OnPush marche par magie » et « je sais pourquoi mon composant ne re-render pas ». Trois briques :
1. Le graphe de dépendances producer → consumer. Un signal est un producer. Un computed, un effect, et — c'est le point clé — le template d'un composant sont des consumers. Quand Angular rend le template d'un composant OnPush, il enregistre quels signals ont été lus. Ce template devient un consumer de ces signals précis. Lire user() dans le template crée un edge user → ce composant.
2. Notification, pas recalcul. Quand vous appelez signal.set(x), Angular ne recalcule rien tout de suite. Il invalide (marque "stale") tous les consumers qui dépendent de ce signal et, pour les consumers de type template, appelle l'équivalent de markForCheck(). Le recalcul (CD) est différé au prochain tick coalescé. C'est du push pour l'invalidation, pull pour la valeur (lazy) — le modèle de Solid/Vue.
3. Propagation glitch-free. Si total = computed(() => a() + b()) et que vous faites a.set(1); b.set(2) dans le même microtask, total n'est recalculé qu'une fois, jamais avec une valeur intermédiaire incohérente (a=1, b=ancien). Angular utilise un système de versions (chaque producer a un compteur ; un computed compare les versions de ses dépendances avant de recalculer). Conséquence pratique : pas de "tearing", pas de double-render.
L'equal function — le piège silencieux
Un signal ne notifie que si la nouvelle valeur est différente de l'ancienne. Par défaut, l'égalité est Object.is (référentielle pour les objets). D'où :
const user = signal({ name: 'Alice' });
user.set({ name: 'Alice' }); // ⚠️ référence DIFFÉRENTE → notifie, CD tourne
user.update((u) => { u.name = 'Bob'; return u; }); // ⚠️ même référence → NE notifie PAS
// Custom equality : éviter une CD si la valeur "logique" n'a pas changé
const status = signal<Status>(initial, {
equal: (a, b) => a.code === b.code, // ignore les champs cosmétiques
});C'est exactement l'analogue de la règle OnPush « changement par référence » — sauf qu'ici vous contrôlez la fonction d'égalité. Un equal custom bien placé peut couper des CD inutiles sur un hot path ; un update qui mute en place est un bug silencieux (« mon UI ne bouge pas »).
effect() ≠ outil de Change Detection — la confusion #1 des seniors qui débarquent
effect() | CD / template | |
|---|---|---|
| Rôle | Side-effects sortant d'Angular : logging, localStorage, lib tierce, sync vers un canvas | Synchroniser le DOM avec l'état |
| Déclencheur | Lecture d'un signal dans l'effect | Lecture d'un signal dans le template |
| Timing | Après la CD, planifié par le scheduler | Pendant le tick |
| Anti-pattern | effect(() => this.b.set(this.a() * 2)) → préférez computed | Calcul lourd inline dans le template |
Règle staff : n'utilisez jamais un effect() pour dériver du state (c'est le job de computed) ni pour « forcer un rendu ». Un effect qui set() un autre signal crée un graphe impératif fragile et peut déclencher l'erreur writing to signals inside effect (bloquée par défaut, déblocable par allowSignalWrites — un code smell). Le template lit déjà les signals : il n'a besoin d'aucun effect pour se rafraîchir.
🛠️ Hybrid mode — Angular 18+ et la coexistence
Angular 18 a introduit un hybrid scheduler qui permet de mixer composants Zone.js et zoneless dans la même app :
bootstrapApplication(AppComponent, {
providers: [
provideExperimentalZonelessChangeDetection(),
// Zone.js peut coexister pendant la migration
],
});Pratique pour migrer une grosse app par étapes — vous gardez certains modules en Zone.js et migrez d'autres en signal-only.
🧰 Patterns spécifiques zoneless
Pattern 1 : interfacer avec une lib qui utilise des callbacks
@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class ThirdPartyCmp {
private cdr = inject(ChangeDetectorRef);
data: any;
ngOnInit() {
legacyLib.subscribe((d: any) => {
// En zoneless, ce callback NE déclenche PAS de CD
this.data = d;
this.cdr.markForCheck(); // obligatoire
});
}
}Solution plus propre : exposer un signal.
@Component({ /* ... */ })
export class ThirdPartyCmp {
data = signal<any>(null);
ngOnInit() {
legacyLib.subscribe(d => this.data.set(d)); // signal -> auto CD
}
}Pattern 2 : WebSocket → signal
@Injectable({ providedIn: 'root' })
export class LiveDataService {
private ws = new WebSocket('wss://...');
data = signal<any>(null);
constructor() {
this.ws.onmessage = e => this.data.set(JSON.parse(e.data));
inject(DestroyRef).onDestroy(() => this.ws.close());
}
}Aucun markForCheck — le signal change, Angular CD se déclenche automatiquement.
Pattern 3 : requestAnimationFrame pour animations DOM
@Component({ /* ... */ })
export class AnimatedCmp {
pos = signal({ x: 0, y: 0 });
constructor() {
// En zoneless, rAF ne déclenche pas Zone.js (qui n'existe plus)
// Mais le signal set déclenche la CD : OK
const loop = (t: number) => {
this.pos.set({ x: Math.cos(t / 1000) * 100, y: Math.sin(t / 1000) * 100 });
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
}Pattern 4 : Intersection Observer + signal
@Directive({ selector: '[appVisible]' })
export class VisibleDirective {
isVisible = signal(false);
constructor(el: ElementRef, destroyRef: DestroyRef) {
const obs = new IntersectionObserver(([entry]) => {
this.isVisible.set(entry.isIntersecting);
});
obs.observe(el.nativeElement);
destroyRef.onDestroy(() => obs.disconnect());
}
}🤖 Senior — Rendre des tokens LLM en streaming sous zoneless
C'est le cas où la Change Detection vous mord en production : un agent renvoie des centaines de tokens par seconde via SSE, et chaque token naïvement rendu = un cycle de CD. Sur Default + Zone.js, c'est 200 ticks/s sur tout l'arbre → l'onglet chauffe. La maîtrise du chapitre paie ici directement.
Le mental model
| Naïf (ce qu'il ne faut pas faire) | Senior (zoneless-ready) |
|---|---|
this.text += chunk dans le callback EventSource | Buffer mutable + signal notifié à cadence rAF |
| Une CD par token (60–200/s) | Une CD par frame (≤ 60/s), peu importe le débit de tokens |
innerHTML = markdown (XSS) | markdown → sanitize via DomSanitizer |
| Pas de Stop → coût serveur qui fuit | AbortController côté client et annulation serveur |
*ngFor sur les messages sans track | @for ... track msg.id (append-only) |
L'idée clé : découpler le débit réseau du débit de rendu. Les tokens arrivent vite ; le DOM n'a pas besoin de suivre token par token — l'œil ne perçoit rien au-delà de 60 fps. On accumule dans un buffer (string brute, hors zone réactive) et on ne set() le signal qu'une fois par frame via requestAnimationFrame. En zoneless, signal.set() est le seul déclencheur de CD : une frame = un tick = un repaint. CPU divisé par 3 à 10 selon le débit.
Lecture du flux : fetch + getReader() + TextDecoder
EventSource est simple mais ne supporte que GET et pas les headers custom (donc pas de Authorization: Bearer). Pour un endpoint LLM authentifié et streamé en POST, on lit le ReadableStream à la main :
// llm-stream.service.ts
import { Injectable, signal, inject, DestroyRef, NgZone } from '@angular/core';
export interface StreamHandle {
/** Texte accumulé, mis à jour à cadence rAF (≤ 60 fps). */
readonly text: import('@angular/core').Signal<string>;
readonly done: import('@angular/core').Signal<boolean>;
readonly error: import('@angular/core').Signal<unknown | null>;
/** Annule la requête côté client ET signale au serveur d'arrêter de générer. */
stop(): void;
}
@Injectable({ providedIn: 'root' })
export class LlmStreamService {
private readonly destroyRef = inject(DestroyRef);
start(url: string, body: unknown): StreamHandle {
const text = signal('');
const done = signal(false);
const error = signal<unknown | null>(null);
const controller = new AbortController();
// Buffer hors-signal : on écrit ici à chaque token (cheap),
// on ne "publie" dans le signal qu'une fois par frame.
let buffer = '';
let dirty = false;
let rafId = 0;
const flush = () => {
rafId = 0;
if (!dirty) return;
dirty = false;
text.set(buffer); // 1 set = 1 CD = 1 repaint, max 1×/frame
};
const scheduleFlush = () => {
if (rafId === 0) rafId = requestAnimationFrame(flush);
};
(async () => {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
for (;;) {
const { value, done: streamDone } = await reader.read();
if (streamDone) break;
// stream: true → ne coupe pas un caractère multi-byte à cheval sur 2 chunks
for (const token of parseSse(decoder.decode(value, { stream: true }))) {
buffer += token;
dirty = true;
scheduleFlush();
}
}
flush(); // dernier flush synchrone pour ne pas perdre la fin
done.set(true);
} catch (e) {
if ((e as Error).name === 'AbortError') return; // Stop volontaire : pas une erreur
error.set(e);
}
})();
const stop = () => {
controller.abort(); // coupe la connexion → le serveur voit le disconnect
if (rafId) cancelAnimationFrame(rafId);
done.set(true);
};
// Nettoyage si le composant qui a lancé le flux est détruit avant la fin.
this.destroyRef.onDestroy(stop);
return { text, done, error, stop };
}
}
/** Parse les lignes `data: {...}` d'un payload SSE et extrait le delta de texte. */
function parseSse(chunk: string): string[] {
const out: string[] = [];
for (const line of chunk.split('\n')) {
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (data === '[DONE]' || !data) continue;
try {
const json = JSON.parse(data);
const delta = json.delta?.text ?? json.choices?.[0]?.delta?.content;
if (delta) out.push(delta);
} catch { /* ligne partielle : ignorée, le prochain chunk complétera */ }
}
return out;
}⚠️
NgZonen'est pas importé par hasard ici : en zoneless il n'existe pas de zone, donc rien à wrapper — c'est le signal qui pilote la CD. Si vous gardez Zone.js, lefetch/reader.read()est patché par Zone et déclencherait une CD à chaqueawait. Le pattern rAF + signal vous protège dans les deux mondes : vous ne payez qu'une CD par frame quoi qu'il arrive. C'est ça, écrire du code « zoneless-ready ».
Le composant : append-only, OnPush, Stop, markdown sanitisé
// chat.component.ts
import {
ChangeDetectionStrategy, Component, signal, inject, computed,
} from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { LlmStreamService, StreamHandle } from './llm-stream.service';
import { marked } from 'marked'; // markdown → HTML
interface ChatMsg {
readonly id: string;
readonly role: 'user' | 'assistant';
readonly handle?: StreamHandle; // présent tant que le message stream
}
@Component({
selector: 'app-chat',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (msg of messages(); track msg.id) {
<article [class.assistant]="msg.role === 'assistant'">
@if (msg.handle) {
<!-- message en cours de streaming -->
<div [innerHTML]="render(msg.handle.text())"></div>
@if (!msg.handle.done()) {
<button (click)="msg.handle.stop()">⏹ Stop</button>
}
@if (msg.handle.error(); as err) {
<p class="err">Erreur : {{ errorText(err) }}</p>
}
}
</article>
}
<button [disabled]="streaming()" (click)="ask('Explique la CD zoneless')">
Envoyer
</button>
`,
})
export class ChatComponent {
private readonly llm = inject(LlmStreamService);
private readonly sanitizer = inject(DomSanitizer);
// Buffer append-only : on ne remplace JAMAIS le tableau en place,
// on en crée un nouveau (référence neuve) pour que @for track réagisse.
readonly messages = signal<readonly ChatMsg[]>([]);
readonly streaming = computed(() =>
this.messages().some((m) => m.handle && !m.handle.done()),
);
ask(prompt: string): void {
const id = crypto.randomUUID();
const handle = this.llm.start('/api/agent/stream', { prompt });
this.messages.update((list) => [
...list,
{ id, role: 'assistant', handle },
]);
}
render(md: string): SafeHtml {
// marked() est synchrone ici ; sanitize obligatoire : un LLM peut émettre du HTML.
return this.sanitizer.sanitize(1 /* SecurityContext.HTML */, marked.parse(md) as string) ?? '';
}
errorText(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
}Pourquoi ça reste fluide même à 200 tokens/s :
- OnPush partout — seul le
<article>dont le signaltextchange est revérifié ; le reste de l'app (header, sidebar) est skippé (dirty path). - 1 CD/frame — le service coalesce les tokens via rAF ; le débit réseau n'impose plus le débit de CD.
@for ... track msg.id— append-only : Angular réutilise les nœuds DOM existants, ne recrée que le nouveau message.computed(streaming)— l'état « Envoyer désactivé » est dérivé, pas calculé dans le template à chaque CD.- Stop = double annulation —
controller.abort()ferme leReadableStream; côté serveur (NestJS), lereq.on('close')/AbortSignaldoit propager l'annulation à l'appel SDK Anthropic (client.messages.stream({ ... }, { signal })) pour arrêter de facturer des tokens dès que l'utilisateur clique. Une annulation client sans annulation serveur = coût qui fuit.
Trace d'agent : une timeline en union discriminée
Pour un agent à tool-use (l'UI montre « réflexion → appel d'outil → réponse »), chaque étape est une union discriminée rendue dans une liste @for track. Le status pilote l'affichage ; comme tout est signal + OnPush, seul l'item dont le statut change re-rend :
type AgentStep =
| { kind: 'thinking'; id: string; status: 'streaming' | 'done' }
| { kind: 'tool_call'; id: string; name: string; status: 'pending' | 'running' | 'done' | 'error'; output?: string }
| { kind: 'message'; id: string; text: import('@angular/core').Signal<string>; status: 'streaming' | 'done' };
// trace = signal<readonly AgentStep[]>([]) ; on .update() en append-only.
// L'optimisme : on insère { status: 'pending' } AVANT la confirmation serveur,
// puis on patche le statut quand l'event arrive — l'UI bouge tout de suite.Côté NestJS (la moitié serveur de votre stack), le miroir de ce pattern : exposer l'endpoint en SSE, streamer les tokens du SDK Anthropic (claude-opus-4-8 en flagship, claude-sonnet-4-6 / claude-haiku-4-5 pour les tâches plus légères), câbler AbortController sur le disconnect client, et injecter le client LLM via forRootAsync (jamais new Anthropic() dans un champ). Le contrat reste le même : le serveur pousse des deltas, le client les coalesce en frames. Voir le chapitre NestJS sur le serving d'agents pour l'autre bout du tuyau.
📊 Benchmark détaillé — exemple concret
Test : appli e-commerce avec 200 composants, liste de 500 produits.
Mesure : temps d'un click "Ajouter au panier" (déclenche refresh du badge)
Stratégie | Temps CD | Composants vérifiés
─────────────────────────────────────────────────────────────────────────
Default + Zone.js | 85 ms | 200 (tous)
OnPush + Zone.js + Inputs ref change | 12 ms | 3 (path dirty)
OnPush + Signals + Zone.js | 8 ms | 3
OnPush + Signals + Zoneless | 5 ms | 3La différence Zone vs Zoneless est plus subtile que OnPush vs Default. Mais sur le CPU idle (pas d'interaction), Zone fait tourner ~5% de CPU en permanence (à cause des polyfills + checks), zoneless = 0%.
⚙️ Debugging tooling
ng.profiler.timeChangeDetection() (legacy debug)
// dans la console DevTools
ng.profiler.timeChangeDetection({ record: true });Mesure le coût d'un cycle de CD. Outdated, préférez Angular DevTools.
Angular DevTools — Profiler
- Ouvrez DevTools → onglet Angular.
- Cliquez Profiler → Start Recording.
- Faites des interactions.
- Stop, explorez le timeline.
Vous voyez :
- Combien de cycles de CD.
- Quels composants ont été checkés.
- Temps de chaque check.
- Cause du check (Markers, Inputs, etc.).
provideCheckNoChangesConfig (dev mode)
import { provideCheckNoChangesConfig } from '@angular/core';
bootstrapApplication(AppComponent, {
providers: [
provideCheckNoChangesConfig({ exhaustive: true, interval: 1000 }),
],
});Configure le check-no-changes du dev mode pour catcher les ExpressionChangedAfterItHasBeenCheckedError plus tôt.
🆚 OnPush ailleurs — comparaison
| Framework | Équivalent OnPush |
|---|---|
| React | React.memo + immutables |
| Vue 3 | Réactivité fine-grained native (proxy) |
| Svelte | Réactivité compile-time |
| Solid | Signals natifs (modèle proche d'Angular 17+) |
| Angular 17+ OnPush+Signals | Modèle de réactivité fine-grained à la Solid |
Angular en 2026 a convergé vers le modèle Solid/Vue : signals comme primitive, OnPush comme défaut, CD ciblée.
🏋️ Exercices
Progression : implémenter → rendre production-grade → casser puis réparer. Chaque exo suppose Angular 20 + standalone + signals.
Exo 1 — Prouver le « dirty path » avec DevTools
Objectif : démontrer empiriquement qu'OnPush skip les sous-arbres non concernés.
Construisez un arbre A → B → C et A → D → E (5 composants OnPush), chacun affichant un compteur de re-render (render-count incrémenté dans le template via une méthode ou un effect de debug). Mettez un signal dans C uniquement. Cliquez un bouton qui fait C.value.update(...).
Indice / Solution
Ouvrez Angular DevTools → Profiler → Record. Après le clic, seuls A → B → C apparaissent dans la flame chart ; D et E ont 0 re-check. Si E re-render aussi, c'est qu'un de vos composants n'est pas réellement OnPush, ou qu'un signal partagé est lu dans D/E. Variante : passez D en Default et observez qu'il casse l'isolation de tout son sous-arbre à chaque tick global (en Zone.js).
Exo 2 — Migrer un flux WebSocket de Default vers OnPush + zoneless
Objectif : faire fonctionner un live-feed temps réel sans Zone.js.
Partez d'un composant Default qui s'abonne à un WebSocket et fait this.items.push(msg) dans onmessage. Migrez : provideZonelessChangeDetection(), retirez zone.js des polyfills, passez tout en OnPush, convertissez l'état en signal<readonly Item[]> mis à jour en immuable (update(l => [...l, msg])), et utilisez @for ... track item.id.
Indice / Solution
Avant migration, en retirant Zone.js, le push ne déclenche plus aucune CD → l'UI gèle. Le fix n'est pas markForCheck() : c'est de remplacer le tableau muté par un signal.set/update immuable. C'est le signal qui notifie le runtime. Vérifiez que le CPU idle tombe à ~0% (DevTools Performance, onglet idle) une fois zoneless actif.
Exo 3 — Coalescer un flux de tokens LLM à 60 fps
Objectif : rendre 200 tokens/s sans dépasser 60 CD/s.
Implémentez le LlmStreamService de la section AI : buffer mutable + requestAnimationFrame → un seul signal.set par frame. Mockez un flux qui émet un token toutes les 5 ms. Mesurez le nombre de cycles de CD avec le Profiler.
Indice / Solution
Sans coalescing (un set() par token), le Profiler montre ~200 ticks/s. Avec rAF, ≤ 60. Piège à reproduire : oublier le flush() synchrone final → les derniers tokens (entre la dernière frame planifiée et la fin du stream) sont perdus si le done arrive avant la frame. Réparez en appelant flush() juste avant done.set(true).
Exo 4 — Casser puis réparer un ExpressionChangedAfterItHasBeenCheckedError
Objectif : comprendre la frontière de CD parent/enfant.
Créez un parent qui lit child.computedTotal() dans son template, où computedTotal dépend d'un signal modifié dans le ngOnInit/constructor du child. En dev mode, déclenchez l'ExpressionChangedAfterItHasBeenCheckedError. Puis réparez de trois façons et expliquez le tradeoff de chacune.
Indice / Solution
Les 3 fixes : (1) afterNextRender(() => ...) pour différer l'écriture hors du cycle de CD courant ; (2) déplacer le calcul dans un computed pur (pas d'effet de bord pendant la CD) — souvent la vraie correction ; (3) queueMicrotask pour repousser au prochain microtask. Tradeoff : (2) est le plus propre (déclaratif, pas de timing), (1) est correct pour de la lecture/écriture DOM, (3) est un hack qui masque souvent un défaut d'architecture. L'erreur n'existe qu'en dev (provideCheckNoChangesConfig) — mais le bug sous-jacent (deux passes divergentes) existe aussi en prod.
Exo 5 — Stop qui annule client ET serveur
Objectif : prouver qu'un AbortController côté Angular arrête la facturation côté serveur.
Branchez le bouton Stop de ChatComponent sur controller.abort(). Côté NestJS (ou un mock Express), loggez req.on('close'). Vérifiez que cliquer Stop ferme le ReadableStream et déclenche le close serveur — qui doit propager l'AbortSignal à client.messages.stream(..., { signal }).
Indice / Solution
Sans propagation serveur : le client se déconnecte mais le SDK Anthropic continue de générer (et facturer) jusqu'au bout. Le fix : passer l'AbortSignal du framework HTTP au SDK. Test de non-régression : un Stop à 10% du flux doit produire usage.output_tokens ≈ 10% du total, pas 100%. C'est un bug coût réel, pas juste UX.
Exo 6 — Détecter le composant « zombie » sous OnPush
Objectif : trouver pourquoi une horloge OnPush ne se met jamais à jour.
Un composant OnPush affiche l'heure via un binding sur now, où now = new Date() est rafraîchi dans un setInterval. Il reste figé. Diagnostiquez et réparez sans repasser en Default.
<!-- le template, figé sous OnPush -->
<span>{{ now }}</span>Indice / Solution
new Date() réassigné dans setInterval ne notifie rien sous OnPush (et rien du tout en zoneless). Trois réparations possibles, par ordre de préférence : (1) now = toSignal(interval(1000).pipe(map(() => new Date()))) — déclaratif, auto-CD ; (2) now = signal(new Date()) + this.now.set(new Date()) dans l'interval ; (3) cdr.markForCheck() après chaque tick — fonctionne mais c'est le symptôme d'un état non-signal. Évitez (3) en code neuf.
🎤 En entretien
Q : Différence exacte entre markForCheck() et detectChanges() ? Quand l'un casse l'autre ? R : markForCheck() marque le composant et ses ancêtres OnPush comme dirty pour le prochain tick (asynchrone, coalescé) — c'est le défaut. detectChanges() exécute la CD immédiatement et synchrone sur ce composant et ses descendants, hors du scheduler. Appeler detectChanges() pendant une CD en cours, ou en boucle, provoque du travail redondant et peut masquer un ExpressionChangedAfter.... On réserve detectChanges() aux cas où on a besoin d'un DOM à jour avant une mesure synchrone (getBoundingClientRect).
Q : En zoneless, qu'est-ce qui déclenche concrètement un cycle de CD ? R : Plus de tick global automatique. Les déclencheurs explicites sont : un signal lu dans un template qui change, un event handler DOM dans un template, le HttpClient signal-aware, l'async pipe, et markForCheck()/ApplicationRef.tick() appelés à la main. Un setTimeout/Promise/callback de lib tierce ne déclenche rien — d'où le pattern « wrapper dans un signal.set ou markForCheck ». Le scheduler coalesce tous les dirty d'un même microtask en un seul tick.
Q : Comment rendriez-vous 200 tokens/s d'un LLM sans tuer le CPU ? R : Découpler débit réseau et débit de rendu. On accumule les tokens dans un buffer mutable hors signal, et on ne fait signal.set(buffer) qu'une fois par frame via requestAnimationFrame. En zoneless, ça donne 1 CD/frame (≤ 60/s) quel que soit le débit, avec OnPush partout pour que seul le message en cours re-render. Plus @for track id append-only et un Stop qui annule client + serveur.
Q : OnPush + un Observable subscribé à la main ne met pas à jour la vue. Pourquoi, et quelle est la meilleure correction ? R : subscribe(x => this.value = x) mute une propriété sans notifier le système de CD — sous OnPush, rien ne marque le composant dirty. La meilleure correction est toSignal(obs$) (ou l'async pipe) : les deux notifient automatiquement et gèrent l'unsubscribe (takeUntilDestroyed implicite pour toSignal). cdr.markForCheck() dans le subscribe marche mais c'est le signe qu'on devrait convertir l'état en signal.
Q : Quelle est la différence entre un effect() et un computed(), et quand l'un est un anti-pattern pour piloter la CD ? R : computed() est une valeur dérivée lazy et glitch-free, lue dans le template — c'est elle qui pilote la CD. effect() est un side-effect (logging, localStorage, lib DOM externe) qui tourne après la CD. Utiliser un effect pour dériver du state (effect(() => this.b.set(this.a() * 2))) est un anti-pattern : il faut un computed. Écrire dans un signal depuis un effect est bloqué par défaut ; le débloquer via allowSignalWrites est un code smell qui signale presque toujours un computed déguisé. Le template lit déjà les signals : il se rafraîchit seul, sans effect.
Q : Pourquoi un signal.update(u => { u.x = 1; return u; }) ne re-render-il pas, alors que signal.set({ ...u, x: 1 }) oui ? R : Un signal ne notifie ses consumers que si la nouvelle valeur diffère de l'ancienne selon sa fonction equal (par défaut Object.is, donc référentielle pour les objets). Muter en place renvoie la même référence → Object.is(old, new) === true → aucune notification → aucune CD. Recréer l'objet (spread) donne une référence neuve → notification → CD. C'est la version "signal" de la règle OnPush « changement par référence d'@Input ». Pour ignorer un changement cosmétique on peut passer un equal custom ({ equal: (a,b) => a.id === b.id }).
🔗 Liens
- https://angular.dev/best-practices/runtime-performance
- https://angular.dev/guide/experimental/zoneless
- https://blog.angular.io/ — annonces zoneless GA
- Angular DevTools : https://angular.dev/tools/devtools
- https://github.com/angular/angular/issues — discussions zoneless
- https://github.com/angular/angular/blob/main/CHANGELOG.md — changelog officiel
- https://www.reddit.com/r/Angular2 — feedback communauté zoneless
- Article fondateur Pawel Kozlowski sur le scheduler interne
📖 Glossaire
- CD (Change Detection) : cycle qui synchronise DOM ↔ state.
- Tick : un cycle complet de CD sur un (sous-)arbre.
- OnPush : stratégie qui skip la vérification sauf signaux explicites (ref change, event, async, signal).
- markForCheck : marquer le composant et ancêtres comme dirty pour le prochain tick.
- detectChanges : forcer un tick synchrone sur ce composant et descendants.
- Zone.js : polyfill qui monkey-patche les API async pour déclencher la CD globale.
- Zoneless : exécution sans Zone.js — CD pilotée par signals + events + markForCheck.
- Hybrid scheduler : permet la coexistence Zone/zoneless en transition.
- Dirty path : chemin du composant dirty à la racine, traversé en OnPush.
- Coalescing : groupage des notifications en un seul tick.
- rAF coalescing : accumuler des updates haute fréquence (tokens LLM) dans un buffer et ne
set()le signal qu'une fois par frame viarequestAnimationFrame— découple débit réseau et débit de CD. - AbortController : annule un
fetch/ReadableStreamcôté client ; en streaming LLM, doit propager l'AbortSignalau serveur pour arrêter la génération (et la facturation). - Append-only buffer : liste de messages mise à jour en immuable (
update(l => [...l, x])) pour que@for ... track idréutilise les nœuds DOM existants.
Récap final
La Change Detection a évolué de "on revérifie tout, tout le temps, parce que Zone.js" vers "on revérifie précisément ce qui a changé, parce que signals". En 2026, le standard est OnPush + Signals + async pipe. Zoneless est GA et c'est la direction recommandée. Si vous n'avez qu'une seule chose à retenir : adoptez OnPush sur tous vos nouveaux composants — c'est gratuit en code, monstrueux en perf, et c'est le pré-requis pour migrer en zoneless sans douleur. Et préparez vos libs 3rd-party : si elles utilisent des callbacks bruts, prévoyez des wrappers markForCheck ou (mieux) migrez-les vers une exposition signal/Observable.