Effects, lifecycle hooks, afterRender — le cycle de vie en 2026
TL;DR —
effect()n'est pas l'équivalent deuseEffectde React : son tracking est automatique sur les signals lus dans le callback (pas de tableau de deps), il est glitch-free (jamais d'état intermédiaire incohérent), et il s'exécute lazily, schedulé par le framework lors de la prochaine synchronisation. Les lifecycle hooks classiques (ngOnInit,ngAfterViewInit,ngOnDestroy) restent utiles pour l'intégration externe et le code legacy.afterRenderetafterNextRender(Angular 16+, signatures basées sur les phases depuis 19) sont les seuls endroits sûrs pour lire/écrire le DOM sans risque de boucle ni deExpressionChangedAfterItHasBeenCheckedError.DestroyRef+takeUntilDestroyedremplacent le patterndestroy$ = new Subject()historique. Anti-pattern critique :effect()qui modifie un autre signal — interdit par défaut ; l'optionallowSignalWritesa été retirée en Angular 19 (utilisezcomputed,linkedSignal, ou un event handler).Le piège mental n°1 (ex-React) : un
effect()n'est pas fait pour synchroniser deux états (a→b). Ça, c'estcomputed/linkedSignal.effect()est réservé aux side effects qui sortent du graphe réactif : DOM impératif,localStorage, logging, analytics, intégration de lib tierce, sockets. Si la sortie de votre effect est un autre signal, c'est un bug d'architecture, pas un détail de runtime.
🧠 Mental model — ASCII + analogie
Imaginez le cycle de vie d'un composant comme un morceau de musique :
constructor → ngOnChanges → ngOnInit → ngDoCheck → ngAfterContentInit
│ │ │ │ │
│ │ │ │ ▼
│ │ │ │ ngAfterContentChecked
│ │ │ │ │
│ │ │ │ ▼
│ │ │ │ ngAfterViewInit
│ │ │ │ │
│ │ │ │ ▼
│ │ │ │ ngAfterViewChecked
│ │ │ │ │
▼ ▼ ▼ ▼ ▼ …
────────────────────────────────────────────────────────────────► time
│
ngOnDestroy ▼
Effect tree (Angular 17+) — interjeté entre les hooks :
constructor: afterRenderEffects (DOM-safe) :
- inject() OK - afterNextRender (1x après prochain render)
- signal/computed creation - afterRender(phase) — Read | Write | MixedReadWrite
- effect() OK ici
- takeUntilDestroyed OK iciAnalogie React : useEffect(() => { ... }, [a, b]) se relance manuellement quand a ou b change. effect(() => { ... s(); other() }) tracke automatiquement les signals lus dans le callback, et se relance quand n'importe lequel change. Pas de tableau de dépendances à maintenir.
afterNextRender = "j'attends que le DOM soit prêt une fois, puis je fais X" (mesure, focus, init de lib externe). afterRender = "je fais X après chaque render" (typiquement pour scroll-sync, lib qui s'auto-positionne).
Le vrai mental model : un graphe réactif à trois zones
La compréhension de senior ne tourne pas autour des hooks ngOn*, mais autour de trois zones de réactivité et de leurs frontières :
┌───────────────────────────────────────────────────────────────┐
│ ZONE 1 — Le graphe pull (pur, glitch-free) │
│ │
│ signal ──▶ computed ──▶ computed ──▶ (template binding) │
│ │ ▲ │
│ │ └── linkedSignal (writable + dérivé) │
│ ▼ │
│ PAS d'effets de bord ici. Tout est pur, lazy, memoïzé. │
└────────────────────────┬──────────────────────────────────────┘
│ frontière : "je sors du graphe"
▼
┌───────────────────────────────────────────────────────────────┐
│ ZONE 2 — Les effects (push vers le monde extérieur) │
│ │
│ effect() ──▶ localStorage / fetch / analytics / WS / log │
│ (lit des signals, écrit AILLEURS que dans le graphe) │
└────────────────────────┬──────────────────────────────────────┘
│ frontière : "je touche le DOM"
▼
┌───────────────────────────────────────────────────────────────┐
│ ZONE 3 — Le DOM (après render, hors CD) │
│ │
│ afterNextRender (1x) / afterRender (chaque render) │
│ phases : earlyRead → write → read (groupées anti-thrash) │
└───────────────────────────────────────────────────────────────┘La règle qui découle de ce modèle, et qui élimine 90 % des bugs : les flèches ne remontent jamais. Un effect (zone 2) ne réécrit pas dans le graphe (zone 1) → sinon boucle. Un afterRender (zone 3) ne set pas un signal lu dans le template → sinon re-render infini. Si vous avez besoin de "remonter", c'est que votre dérivation devait être un computed/linkedSignal dès le départ.
Quand l'effect s'exécute-t-il vraiment ? (Angular 17 → 20)
Mythe à corriger : "l'effect tourne au prochain microtask". C'est faux depuis Angular 19. Le timing exact :
| Aspect | Réalité (Angular 19/20) |
|---|---|
| Premier run | Lazy : schedulé à la création, exécuté lors de la prochaine synchronisation du framework (pas dans le constructor, pas garanti sur un microtask précis). |
| Re-run | Schedulé quand une dépendance change, exécuté pendant la phase de synchronisation de la CD, avant le render. |
| Coalescing | Plusieurs set() synchrones → un seul re-run, avec la valeur finale. |
| Zoneless (20 GA) | L'effect participe au ApplicationRef.tick(). Pas de Zone.js pour le déclencher : c'est le scheduler de signals qui demande un tick. |
| Forcer en test | TestBed.tick() (Angular 19+ ; flushEffects() est déprécié). |
Conséquence pratique : ne jamais raisonner sur l'ordre exact entre un effect et le code synchrone qui suit sa création. Si vous avez besoin d'un ordre déterministe, vous voulez probablement du code impératif dans un event handler, pas un effect.
🛠️ Code minimal (ts + html)
Hooks classiques + DestroyRef moderne
import {
Component, OnInit, OnDestroy, AfterViewInit, DestroyRef, inject,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
@Component({ /* ... */ })
export class LegacyStyle implements OnInit, AfterViewInit, OnDestroy {
private destroyRef = inject(DestroyRef);
ngOnInit() {
// Lecture inputs OK, état initialisé, DOM PAS encore prêt
interval(1000)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(t => console.log(t));
}
ngAfterViewInit() {
// ViewChild résolus, DOM prêt
// ATTENTION : modifier un input ou un signal ici déclenche
// ExpressionChangedAfterItHasBeenCheckedError sans afterNextRender
}
ngOnDestroy() {
// takeUntilDestroyed nettoie déjà — ngOnDestroy reste utile
// pour fermer des ressources non-Rx (WebWorker, EventSource, etc.)
}
}Effect — signal-aware
import { Component, effect, signal, computed } from '@angular/core';
@Component({ /* ... */ })
export class Modern {
count = signal(0);
double = computed(() => this.count() * 2);
constructor() {
// Effect créé dans le constructor (injection context)
effect((onCleanup) => {
const c = this.count();
console.log('count is', c, 'double is', this.double());
const id = setTimeout(() => console.log('delayed', c), 1000);
onCleanup(() => clearTimeout(id)); // exécuté avant le prochain run et au destroy
});
}
inc() { this.count.update(c => c + 1); }
}Chaque set() sur count :
- Marque l'effect dirty.
- À la prochaine synchronisation du framework, le scheduler appelle le
onCleanupdu run précédent. - Puis exécute le nouveau callback.
afterRender / afterNextRender
import { Component, afterNextRender, afterRender, viewChild, ElementRef, signal } from '@angular/core';
@Component({
template: `<canvas #canvas width="400" height="200"></canvas>`,
})
export class CanvasCmp {
canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');
fps = signal(0);
constructor() {
// S'exécute UNE SEULE FOIS après le 1er render (équivalent ngAfterViewInit safe)
afterNextRender(() => {
const ctx = this.canvas().nativeElement.getContext('2d')!;
this.drawFrame(ctx);
});
// S'exécute à CHAQUE render — utile pour sync DOM.
// Angular 19+ : API basée sur les phases via l'objet { read, write, ... }.
afterRender({
// earlyRead : lire le DOM AVANT toute écriture de ce cycle
earlyRead: () => this.canvas().nativeElement.clientHeight,
// write : appliquer les mutations DOM (style, scroll, attrs)
write: (height) => {
// height = valeur retournée par earlyRead (chaînage typé)
// ... applique une hauteur dérivée, par ex.
},
// read : relire après écriture si besoin (rare)
read: () => {
const h = this.canvas().nativeElement.clientHeight;
},
});
}
drawFrame(ctx: CanvasRenderingContext2D) { /* ... */ }
}Les phases earlyRead → write → mixedReadWrite → read minimisent le layout thrashing : Angular groupe tous les earlyRead/read (lecture) d'un côté et tous les write de l'autre, à travers tous les afterRender enregistrés. La valeur retournée par earlyRead est passée en argument à write (pipeline typé). Lire et écrire dans la même phase (mixedReadWrite) force un reflow synchrone — à éviter sauf nécessité absolue.
Note version : la signature à callback unique (
afterRender(() => {...})) reste valide pour un cas simple ; elle s'exécute en phasemixedReadWrite. La forme à phases est celle à privilégier dès qu'on lit et écrit le DOM.
Signals dans constructor vs ngOnInit
@Component({ /* ... */ })
export class Timing {
data = input<Item[]>([]); // signal input
count = computed(() => this.data().length);
constructor() {
// OK : injection context, effect tracke this.data automatiquement
effect(() => console.log('data changed, count =', this.count()));
// BAD si on essaie d'utiliser un signal qui dépend d'un service injecté tard.
// En pratique, constructor = bon endroit pour 90% du code "init".
}
ngOnInit() {
// En ngOnInit, les inputs SONT déjà résolus (depuis Angular 16+).
// Pour les signal inputs, this.data() lit la valeur initiale ici aussi.
console.log('init with', this.data());
}
}Différence clé Angular 16+ : avec les input() signal-based, les inputs sont disponibles dès le constructor via la fonction signal. Plus besoin d'attendre ngOnInit. Cela rend ngOnInit quasi-obsolète pour beaucoup de cas.
🎯 Patterns courants
1. Sync signal vers localStorage
@Component({ /* ... */ })
export class PrefsCmp {
theme = signal<'light' | 'dark'>(
(localStorage.getItem('theme') as any) ?? 'light',
);
constructor() {
effect(() => {
localStorage.setItem('theme', this.theme());
});
}
}Pur side effect, parfait pour effect(). Pas de dépendance autre que this.theme.
2. Init de lib externe au mount (DOM-safe)
@Component({ /* ... */ })
export class MapCmp {
private container = viewChild.required<ElementRef<HTMLDivElement>>('map');
constructor() {
afterNextRender(() => {
const map = L.map(this.container().nativeElement).setView([48.85, 2.35], 13);
L.tileLayer(/* ... */).addTo(map);
inject(DestroyRef).onDestroy(() => map.remove());
});
}
}afterNextRender garantit que le div est dans le DOM, mesurable, et que la lib externe peut s'y attacher proprement.
3. Cleanup explicite via DestroyRef.onDestroy
@Component({ /* ... */ })
export class WebSocketCmp {
constructor() {
const destroyRef = inject(DestroyRef);
const ws = new WebSocket('wss://...');
ws.onmessage = (m) => this.handle(m);
destroyRef.onDestroy(() => ws.close());
}
}DestroyRef.onDestroy est l'équivalent fonctionnel de ngOnDestroy, mais utilisable depuis n'importe quel injection context, y compris dans une fonction utilitaire.
4. Eviter ExpressionChangedAfterItHasBeenCheckedError
@Component({ /* ... */ })
export class TooltipCmp {
width = signal(0);
private el = viewChild.required<ElementRef<HTMLDivElement>>('box');
constructor() {
// BAD : modifier un signal lu dans le template parent pendant ngAfterViewInit
// ngAfterViewInit() { this.width.set(this.el.nativeElement.clientWidth); }
// GOOD : afterNextRender — out of the CD pass
afterNextRender(() => {
this.width.set(this.el().nativeElement.clientWidth);
});
}
}5. Coalescing — plusieurs sets dans un effect
effect(() => {
// 3 set synchrones -> l'effect ne re-run qu'UNE fois à la prochaine synchronisation
// avec les valeurs finales. Pas de "flicker", pas d'intermédiaire visible.
});C'est le glitch-free guarantee des signals. Un computed/effect ne voit jamais d'état intermédiaire incohérent.
🔄 Versions — Angular 16 → 20
| Version | Apport sur les effects / lifecycle |
|---|---|
| 16 | DestroyRef, takeUntilDestroyed. afterRender, afterNextRender. effect() (preview). |
| 17 | effect() stable. Signals stable. mutate() retiré. |
| 17.3 | Signal inputs / queries / outputs en developer preview. viewChild, contentChild signal-based. |
| 18 | allowSignalWrites désactivé par défaut dans effect() — vous devez utiliser un autre mécanisme (event handler, computed) pour modifier des signals. Signal queries stable. TestBed.tick() introduit. |
| 18.1 | TestBed.flushEffects() déprécié au profit de TestBed.tick(). |
| 19 | allowSignalWrites retiré de l'API (plus une option du tout). resource() stable (preview→stable selon canal). linkedSignal(). httpResource(). Effects rattachés à la synchronisation du framework. |
| 20 | Zoneless GA (provideZonelessChangeDetection()) — afterRender devient encore plus critique car c'est l'unique entrée DOM-safe ; les effects participent directement au tick() sans Zone.js. |
allowSignalWrites — l'histoire complète (et pourquoi il a disparu)
// Angular 18 : write refusé par défaut, échappatoire dispo mais déconseillée
effect(() => {
this.a.set(this.b() * 2);
}, { allowSignalWrites: true });
// Angular 19+ : l'option N'EXISTE PLUS. Ce code ne compile pas.
// Écrire un signal dans un effect lève une erreur sans échappatoire.Le framework refuse car c'est typiquement une boucle ou un pattern qui devrait être déclaratif. Le bon outil selon l'intention :
| Intention | Mauvais (effect qui set) | Bon |
|---|---|---|
a est purement dérivé de b | effect(() => this.a.set(this.b()*2)) | a = computed(() => this.b()*2) |
a est dérivé de b mais modifiable par l'utilisateur (reset sur changement de source) | effect + flag | a = linkedSignal(() => this.b()*2) |
| Réagir à un event utilisateur en mutant l'état | effect sur un signal d'event | event handler explicite (click)="..." |
| Synchroniser deux états bidirectionnellement | deux effects croisés (boucle !) | un seul linkedSignal source de vérité, ou repenser le modèle |
linkedSignal (Angular 19) est la réponse au cas le plus fréquent qui poussait les gens vers allowSignalWrites : "un état writable qui se réinitialise quand une source change".
// Sélection qui se reset quand la liste source change, mais reste modifiable
products = input.required<Product[]>();
selectedId = linkedSignal<Product[], string | null>({
source: this.products,
computation: (list, previous) => {
// garde la sélection précédente si elle existe encore, sinon le 1er produit
const keep = previous && list.find((p) => p.id === previous.value);
return keep?.id ?? list[0]?.id ?? null;
},
});
// selectedId.set('x') reste possible ; un nouveau products() recalcule.⚠️ Pitfalls — 10 erreurs qui mordent
effectqui set un autre signal (boucle infinie) — l'erreur la plus fréquente. Interdit par défaut depuis Angular 18, sans échappatoire depuis 19 (allowSignalWritesretiré). Toujours préférercomputed; pour un état writable dérivé d'une source,linkedSignal; si c'est une action utilisateur, un event handler explicite. Rappel : la règle vise le corps synchrone de l'effect — unsetdans un.then/callback async déclenché plus tard est légal.effect()créé hors injection context —effect()doit être créé dans un constructor, field initializer, ourunInInjectionContext. Sinon : runtime error. Pour les services, créez les effects dans le constructor du service.Lecture DOM dans
ngAfterViewInit— souvent provoqueExpressionChangedAfterItHasBeenCheckedError. UtilisezafterNextRender.afterRenderdans une boucle de calcul de layout —afterRenderse ré-exécute à chaque CD. Si vous mesurez puis modifiez un signal lu dans le template, vous boucle. PréférezafterNextRenderpour les one-shots.Oublier
onCleanup—effect((onCleanup) => { const id = setTimeout(...); onCleanup(() => clearTimeout(id)); }). SansonCleanup, vos timers s'accumulent à chaque re-run.takeUntilDestroyed()sans arg hors injection context — pareil que pour effect. Passez explicitementtakeUntilDestroyed(this.destroyRef).Mélanger
ngOnDestroyetDestroyRef.onDestroy— choisissez un style. Mélanger rend la teardown order opaque. Préférence :DestroyRef.onDestroy(composable, fonctionnel) pour le nouveau code.Signals inputs lus dans le constructor avant qu'Angular les set — pour les
input.required<T>(), lire dans le constructor lance une erreur car la valeur n'est pas encore attribuée. Lecture safe : dans uncomputed, uneffect, oungOnInit(et au-delà).afterRenderqui modifie un signal lu dans le template — boucle de render.afterRenderdoit ÊTRE le point terminal de la CD, pas une source de nouveau state.ngDoCheckutilisé comme "useEffect with no deps" —ngDoChecktourne à chaque cycle de CD, c'est très coûteux. En 2026, vous ne devriez quasiment jamais l'utiliser. Les seuls cas légitimes : intégration de libs externes qui mutent des objects (sans déclencher CD) et qu'on veut détecter manuellement. Mieux : passer par un signal.effectqui fait du HTTP — fonctionnel mais le HTTP n'est pas cancellable et peut s'empiler. Préférezresource()outoSignal(http$).
🧪 Testing — fakeAsync, TestBed.runInInjectionContext, TestBed.tick
Effect — TestBed.tick() (Angular 19+)
import { TestBed } from '@angular/core/testing';
it('effect runs after signal change', () => {
TestBed.runInInjectionContext(() => {
const s = signal(0);
const captured: number[] = [];
effect(() => captured.push(s()));
TestBed.tick(); // 1er run (remplace flushEffects, déprécié 18.1)
s.set(1);
s.set(2);
s.set(3); // 3 sets sync
TestBed.tick(); // coalesced -> 1 run avec 3
expect(captured).toEqual([0, 3]);
});
});Migration : si votre base utilise encore
TestBed.flushEffects(), c'est l'API dépréciée (Angular 18.1+). Remplacez parTestBed.tick(), qui exécute toute la synchronisation (effects et afterRender), pas seulement les effects. Pour un composant monté,fixture.detectChanges()suffit souvent à déclencher les effects attachés.
afterRender — fixture.detectChanges() + flushAfterRenderEffects
it('afterNextRender measures DOM', fakeAsync(() => {
const fixture = TestBed.createComponent(CanvasCmp);
fixture.detectChanges(); // 1er render
tick(); // flush microtasks
// afterNextRender callback s'est exécuté
expect(fixture.componentInstance.fps()).toBeGreaterThanOrEqual(0);
}));DestroyRef — vérifier le cleanup
it('cleanup on destroy', () => {
const fixture = TestBed.createComponent(WebSocketCmp);
fixture.detectChanges();
const spy = spyOn(fixture.componentInstance['ws'], 'close');
fixture.destroy();
expect(spy).toHaveBeenCalled();
});Comparaison philosophie React — pour les profils React/Vue
// React
useEffect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, [/* deps explicites */]);
// Angular (signal-driven)
effect((onCleanup) => {
// dépendances trackées AUTOMATIQUEMENT sur les signals lus
const interval = this.intervalMs(); // signal
const id = setInterval(tick, interval);
onCleanup(() => clearInterval(id));
});Différences :
- React : tableau de deps explicite, vous oubliez une dep = bug.
- Angular : tracking auto, vous lisez un signal = il est trackée, vous ne le lisez pas = il ne l'est pas.
- React : se relance après le commit, async par rapport au render.
- Angular : schedulé à la prochaine synchronisation du framework (avant le render), glitch-free.
- React :
useEffectpeut modifier le state (autorisé mais cause re-render). - Angular :
effectne peut pas set un autre signal par défaut (Angular 18+).
🎬 Cas d'usage concrets
Scénario 1 — SaaS RH, tracking analytics piloté par signaux
Une plateforme RH veut tracker dans Mixpanel les changements de filtres (« filter_changed »), les ouvertures de fiches candidats (« candidate_opened »), et les soumissions de formulaires. L'ancienne approche : un appel analytics.track(...) éparpillé à chaque endroit qui mute le state — fastidieux, oubliable, et duplique la logique.
L'équipe centralise via effect(). Chaque signal d'état « notable » est watché par un effect dédié dans un AnalyticsCoordinator. Exemple : effect(() => { const f = filtersStore.current(); analytics.track('filter_changed', f); }). Quand filtersStore.current change, l'effect se déclenche automatiquement. Le code métier reste pur ; le tracking est déclaratif.
Piège évité : éviter allowSignalWrites dans un effect d'analytics. Les effects de log ne doivent rien muter — c'est l'invariant de pureté qui rend le système prédictible. La règle d'équipe : si on a besoin d'écrire dans un signal depuis un effect, c'est probablement un computed déguisé.
Scénario 2 — E-commerce, hydration data au mount
Une fiche produit doit charger : produit, stock, recommandations, avis. Avant : ngOnInit avec 4 souscriptions imbriquées ou un forkJoin. L'équipe migre vers signaux : un input.required<string> pour productId, et un effect(() => { const id = productId(); loadAll(id); }) qui déclenche le chargement.
Avantage : si productId change (route param update sans navigation), l'effect se déclenche automatiquement. Avant, il fallait s'abonner à route.paramMap et orchestrer manuellement. L'effect retourne aussi un cleanup (onCleanup(() => abort.abort())) qui annule le fetch en cours via AbortController, ce qui élimine les courses lors d'une navigation rapide.
Détail : effect() peut être créé dans un constructor (contexte d'injection garanti). L'équipe a une règle : « toute synchronisation input → action se déclare dans le constructor avec un effect, pas dans ngOnInit ». Plus prévisible, plus testable, et compatible avec une éventuelle migration zoneless.
Scénario 3 — Cabinet juridique, listener resize avec lifecycle
Un portail juridique affiche un éditeur de mémoire qui doit adapter sa colonne de pagination quand on redimensionne la fenêtre. L'ancien code : @HostListener('window:resize') qui appelle markForCheck() — fonctionnel mais lié au composant.
L'équipe centralise dans un ViewportService injecté providedIn: 'root' : width = signal(window.innerWidth). Dans le constructor, un listener window qui appelle width.set(...). Cleanup via DestroyRef. Côté composant, on lit viewport.width() ou un dérivé isMobile = computed(() => viewport.width() < 768).
Tous les composants qui dépendent de la largeur se rafraîchissent automatiquement quand le signal change. Plus de @HostListener éparpillés. Et le service est mockable en tests : provideViewportMock({ width: 320 }) simule un mobile sans manipuler window.
🛠️ Exemple end-to-end
Use case : page produit e-commerce. input productId déclenche un effect de chargement avec cleanup (AbortController), effect de tracking analytics, et effect de persistance lastViewed dans localStorage.
// product.api.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface Product {
id: string;
name: string;
priceCents: number;
description: string;
}
@Injectable({ providedIn: 'root' })
export class ProductApi {
private readonly http = inject(HttpClient);
getById(id: string, signal: AbortSignal): Promise<Product> {
return fetch(`/api/products/${id}`, { signal }).then((r) => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json() as Promise<Product>;
});
}
}// analytics.service.ts
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
track(event: string, payload: Record<string, unknown>): void {
// window.mixpanel?.track(event, payload);
console.debug('[track]', event, payload);
}
}// last-viewed.store.ts
import { Injectable, effect, signal } from '@angular/core';
const KEY = 'lastViewedProducts.v1';
const MAX = 10;
@Injectable({ providedIn: 'root' })
export class LastViewedStore {
private readonly _ids = signal<ReadonlyArray<string>>(this.restore());
readonly ids = this._ids.asReadonly();
constructor() {
effect(() => localStorage.setItem(KEY, JSON.stringify(this._ids())));
}
push(id: string): void {
this._ids.update((arr) => [id, ...arr.filter((x) => x !== id)].slice(0, MAX));
}
private restore(): string[] {
try {
return JSON.parse(localStorage.getItem(KEY) ?? '[]') as string[];
} catch {
return [];
}
}
}// product.page.ts
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
input,
signal,
} from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { ProductApi, Product } from './product.api';
import { AnalyticsService } from './analytics.service';
import { LastViewedStore } from './last-viewed.store';
type LoadState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'error'; message: string }
| { kind: 'loaded'; product: Product };
@Component({
selector: 'app-product-page',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CurrencyPipe],
template: `
@switch (state().kind) {
@case ('loading') { <p>Chargement…</p> }
@case ('error') { <p class="error">{{ errorMessage() }}</p> }
@case ('loaded') {
<h1>{{ product()!.name }}</h1>
<p>{{ product()!.priceCents / 100 | currency: 'EUR' }}</p>
<p>{{ product()!.description }}</p>
}
}
`,
})
export class ProductPage {
private readonly api = inject(ProductApi);
private readonly analytics = inject(AnalyticsService);
private readonly lastViewed = inject(LastViewedStore);
readonly productId = input.required<string>();
protected readonly state = signal<LoadState>({ kind: 'idle' });
protected readonly product = computed(() => {
const s = this.state();
return s.kind === 'loaded' ? s.product : null;
});
protected readonly errorMessage = computed(() => {
const s = this.state();
return s.kind === 'error' ? s.message : '';
});
constructor() {
// Effect 1 : recharge dès que productId change, avec cleanup via AbortController
effect((onCleanup) => {
const id = this.productId();
const ctrl = new AbortController();
this.state.set({ kind: 'loading' });
this.api
.getById(id, ctrl.signal)
.then((product) => this.state.set({ kind: 'loaded', product }))
.catch((err: Error) => {
if (err.name !== 'AbortError') {
this.state.set({ kind: 'error', message: err.message });
}
});
onCleanup(() => ctrl.abort());
});
// Effect 2 : tracking analytics quand un produit est chargé
effect(() => {
const p = this.product();
if (p) this.analytics.track('product_viewed', { id: p.id, name: p.name });
});
// Effect 3 : persistance liste « vu récemment »
effect(() => {
const p = this.product();
if (p) this.lastViewed.push(p.id);
});
}
}Trois effects, trois responsabilités strictement séparées. Pas de ngOnInit, pas de ngOnDestroy, pas de souscription manuelle. Le cleanup de l'AbortController gère les courses (navigation rapide entre fiches produits), et le pattern est purement déclaratif.
⚠️ Subtilité de senior dans l'exemple ci-dessus : l'Effect 1 lit
productId()puis fait unfetchasync. Le.thenqui appellethis.state.set(...)s'exécute hors du turn synchrone de l'effect — donc ce n'est pas un "write dans un effect" interdit. La règle "un effect ne set pas de signal" s'applique au corps synchrone de l'effect (au moment du tracking). Unsetdans un callback async/promise/event déclenché plus tard est parfaitement légal. C'est exactement ce qui rend le pattern "effect de chargement" viable. Idem pour l'Effect 3 :lastViewed.pushmute un signal d'un autre service, ce qui techniquement passe — mais préférez quand même que la persistance vive dans le store lui-même pour garder l'effect du composant pur. Dans une vraie revue de code, on déplaceraitpushdans un handler explicite.
🤖 Lifecycle au service d'une UI d'agent IA (streaming)
C'est l'application "réelle" qui condense tout ce chapitre : afficher la réponse token-par-token d'un agent (Claude via votre backend NestJS), avec un bouton Stop qui annule des deux côtés, le tout zoneless-ready. Aucune lib magique — juste signal, effect, afterNextRender, DestroyRef et AbortController.
Mental model du streaming réactif
backend NestJS (SSE) ─tokens─▶ fetch().getReader() ─▶ TextDecoder
▲ │
│ AbortController.signal ▼
│ (Stop = annule serveur ET client) append-only buffer (signal)
│ │
POST /chat ▼
rAF-coalesced flush ─▶ signal set
│
▼
template (zoneless) ─▶ DOM
│
afterNextRender ─▶ autoscroll basQuatre invariants de senior :
- Buffer append-only : on n'édite jamais un message passé, on pousse. Ça rend le rendu trivialement memoïzable et
@foravectrack idne re-render que la dernière bulle. - Coalescing par
requestAnimationFrame: un LLM peut émettre 50–100 tokens/s. Unsignal.setpar token = 100 CD/s. On accumule dans un buffer mutable et on flush dans un signal une fois par frame (~60 Hz). Indispensable en zoneless où chaque set demande un tick. - Annulation à deux bouts :
AbortControllerannule lefetchclient et propage l'abort au serveur (qui doit lui-mêmeabort()l'appel au SDK Anthropic — voir le pendant NestJS). Sans ça, vous payez des tokens pour une réponse que personne ne lit. - Cleanup via
DestroyRef: si le composant est détruit pendant un stream (navigation), on abort. Sinon : fuite de socket + writes sur un composant mort.
Le service de streaming
import { Injectable, signal, inject, DestroyRef } from '@angular/core';
export type StreamStatus = 'idle' | 'streaming' | 'done' | 'error' | 'aborted';
interface AgentMessage {
readonly id: string;
readonly role: 'user' | 'assistant';
readonly text: string; // accumulé
}
@Injectable({ providedIn: 'root' })
export class AgentChatService {
private readonly destroyRef = inject(DestroyRef);
private readonly _messages = signal<readonly AgentMessage[]>([]);
readonly messages = this._messages.asReadonly();
private readonly _status = signal<StreamStatus>('idle');
readonly status = this._status.asReadonly();
private controller: AbortController | null = null;
// buffer mutable hors signal + flush coalescé en rAF
private pendingText = '';
private rafId: number | null = null;
private streamingId: string | null = null;
constructor() {
// si le service "root" mourait (rare), on annule — mais surtout
// on annule au unmount du composant via stop() appelé par DestroyRef.
this.destroyRef.onDestroy(() => this.stop());
}
async send(prompt: string): Promise<void> {
this.stop(); // annule un stream précédent éventuel
const userId = crypto.randomUUID();
const assistantId = crypto.randomUUID();
this.streamingId = assistantId;
this._messages.update((m) => [
...m,
{ id: userId, role: 'user', text: prompt },
{ id: assistantId, role: 'assistant', text: '' },
]);
this._status.set('streaming');
this.controller = new AbortController();
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ prompt, generationId: assistantId }), // idempotence côté serveur
signal: this.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 } = await reader.read();
if (done) break;
// parse SSE minimal : lignes "data: <token>"
const chunk = decoder.decode(value, { stream: true });
for (const line of chunk.split('\n')) {
if (line.startsWith('data: ')) this.enqueue(line.slice(6));
}
}
this.flush(); // flush final
this._status.set('done');
} catch (err) {
if ((err as Error).name === 'AbortError') this._status.set('aborted');
else this._status.set('error');
} finally {
this.controller = null;
this.streamingId = null;
}
}
/** Stop = annule client + serveur (le serveur lit la déconnexion / l'abort). */
stop(): void {
this.controller?.abort();
this.controller = null;
if (this.rafId !== null) { cancelAnimationFrame(this.rafId); this.rafId = null; }
}
// ── coalescing rAF : 1 flush/frame, pas 1 set/token ──
private enqueue(token: string): void {
this.pendingText += token;
if (this.rafId === null) {
this.rafId = requestAnimationFrame(() => this.flush());
}
}
private flush(): void {
this.rafId = null;
if (!this.pendingText || !this.streamingId) return;
const delta = this.pendingText;
this.pendingText = '';
const id = this.streamingId;
this._messages.update((list) =>
list.map((m) => (m.id === id ? { ...m, text: m.text + delta } : m)),
);
}
}Pourquoi requestAnimationFrame et pas un effect() ? Parce que le débit vient d'une source externe (le reader), pas d'un signal. L'effect réagit aux signals, pas aux events réseau. On utilise donc le primitive impératif correct (rAF) pour coalescer, puis on entre dans le graphe réactif par un seul set par frame. C'est exactement la frontière "zone 2 → zone 1" du mental model, mais à l'envers : monde extérieur → graphe.
Le composant + autoscroll via afterRender
import {
ChangeDetectionStrategy, Component, computed, inject,
afterRender, viewChild, ElementRef, signal,
} from '@angular/core';
import { SecurityContext } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { AgentChatService } from './agent-chat.service';
@Component({
selector: 'app-agent-chat',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div #scroll class="log">
@for (msg of chat.messages(); track msg.id) {
<article [class.user]="msg.role === 'user'">
<div [innerHTML]="render(msg.text)"></div>
</article>
}
</div>
<form (submit)="onSubmit($event)">
<input [value]="draft()" (input)="draft.set($any($event.target).value)" />
@if (chat.status() === 'streaming') {
<button type="button" (click)="chat.stop()">Stop</button>
} @else {
<button type="submit">Envoyer</button>
}
</form>
`,
})
export class AgentChatComponent {
protected readonly chat = inject(AgentChatService);
private readonly sanitizer = inject(DomSanitizer);
private readonly scroll = viewChild.required<ElementRef<HTMLDivElement>>('scroll');
protected readonly draft = signal('');
// autoscroll : on suit la longueur du dernier message
private readonly lastLen = computed(
() => this.chat.messages().at(-1)?.text.length ?? 0,
);
constructor() {
// afterRender : DOM-safe pour lire scrollHeight et écrire scrollTop.
// Phases : on lit (earlyRead) puis on écrit (write) -> pas de thrashing.
afterRender({
earlyRead: () => {
this.lastLen(); // tracke la croissance -> re-render -> ce hook re-tourne
const el = this.scroll().nativeElement;
// "near bottom" : ne pas voler le scroll si l'user a remonté lire
const nearBottom =
el.scrollHeight - el.scrollTop - el.clientHeight < 80;
return { el, nearBottom, target: el.scrollHeight };
},
write: ({ el, nearBottom, target }) => {
if (nearBottom) el.scrollTop = target;
},
});
}
onSubmit(e: Event): void {
e.preventDefault();
const p = this.draft().trim();
if (!p) return;
this.draft.set('');
void this.chat.send(p);
}
// markdown -> HTML *sanitizé*. Ne JAMAIS bypasser la sanitization sur
// du contenu LLM : le modèle peut émettre <img onerror=...> ou <script>.
protected render(text: string): SafeHtml {
const html = markdownToHtml(text); // votre lib (marked, etc.)
return this.sanitizer.sanitize(SecurityContext.HTML, html) ?? '';
}
}Points de sécurité/perf qu'un staff engineer vérifie en revue :
- DomSanitizer obligatoire sur la sortie LLM. Un modèle peut être prompt-injecté pour produire du HTML hostile.
bypassSecurityTrustHtmlsur du contenu d'agent = faille XSS directe. Onsanitize, on nebypassjamais. track msg.iddans@for: sans clé stable, chaque token re-crée tout le DOM de la conversation. Avec, seule la dernière bulle se patche.afterRenderlitlastLen()pour se ré-exécuter à chaque ajout de token, mais ne set aucun signal lu dans le template → pas de boucle.- Le bouton Stop appelle
chat.stop()→AbortController.abort()→ lefetchrejetteAbortErrorET le serveur voit la connexion fermée. Côté NestJS, branchez lereq.on('close')sur unAbortControllerpassé au SDK Anthropic (client.messages.stream({...}, { signal })) pour stopper la facturation des tokens. - Idempotence : on envoie un
generationId(= l'assistantId). Si l'utilisateur réessaie, le backend dé-duplique sur cette clé (utile avec une file BullMQ : la même génération ne se relance pas, on rejoue le buffer partiel).
Pourquoi
effect()ne pilote PAS le stream : on pourrait être tenté de faireeffect(() => sendIfPromptChanged(this.prompt())). Mauvais. Envoyer une requête réseau est un side effect impératif déclenché par un event utilisateur (le submit), pas une dérivation d'état. Le mettre dans un effect le rend re-jouable de façon imprévisible et fragile au refactor. Règle : les actions utilisateur → handlers ; les dérivations → computed ; les synchronisations sortantes pures → effect.
Le pendant NestJS — le contrôleur SSE qui annule la facturation
Le chat.stop() côté Angular est inutile si le serveur continue à brûler des tokens. Le lifecycle Angular (DestroyRef/AbortController) a un miroir exact côté NestJS : quand le client ferme la connexion (Stop, navigation, onglet fermé), le serveur doit propager l'abort au SDK Anthropic pour arrêter la génération — donc la facturation. Le client LLM est injecté via DI (forRootAsync), jamais new Anthropic() dans un champ : testabilité, config centralisée, retries SDK partagés.
// anthropic.module.ts — client DI'd, pas un `new Anthropic()` éparpillé
import { Module, Global } from '@nestjs/common';
import Anthropic from '@anthropic-ai/sdk';
import { ConfigService } from '@nestjs/config';
export const ANTHROPIC = Symbol('ANTHROPIC');
@Global()
@Module({
providers: [
{
provide: ANTHROPIC,
inject: [ConfigService],
// forRootAsync-style : la clé vient de la config, pas d'un import.meta
useFactory: (config: ConfigService) =>
new Anthropic({
apiKey: config.getOrThrow<string>('ANTHROPIC_API_KEY'),
maxRetries: 3, // le SDK retry 429/5xx en backoff exponentiel
}),
},
],
exports: [ANTHROPIC],
})
export class AnthropicModule {}// chat.controller.ts — SSE manuel + AbortController câblé sur la déconnexion client
import {
Controller, Post, Body, Res, Req, Inject, HttpException,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import type Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from './anthropic.module';
@Controller('chat')
export class ChatController {
constructor(@Inject(ANTHROPIC) private readonly anthropic: Anthropic) {}
@Post()
async stream(
@Body() body: { prompt: string; generationId: string },
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
// LE point clé : la déconnexion client → abort serveur → stop facturation.
// C'est le miroir exact de DestroyRef.onDestroy / onCleanup côté Angular.
const ac = new AbortController();
req.on('close', () => ac.abort());
try {
const stream = this.anthropic.messages.stream(
{
model: 'claude-opus-4-8', // flagship ; haiku-4-5 pour du throughput low-cost
max_tokens: 64_000, // streaming → on peut viser haut sans timeout HTTP
thinking: { type: 'adaptive' }, // adaptatif : pas de budget_tokens (retiré sur 4.7+)
messages: [{ role: 'user', content: body.prompt }],
},
{ signal: ac.signal }, // ← propage l'abort jusqu'au SDK
);
// on ne pousse QUE les deltas texte ; un client robuste parse `data: <token>`
stream.on('text', (delta) => {
res.write(`data: ${delta.replace(/\n/g, '\\n')}\n\n`);
});
await stream.finalMessage(); // collecte/erreurs/abort gérés par le SDK
res.write('event: done\ndata: {}\n\n');
} catch (err) {
// AbortError = client parti, pas une vraie erreur : on ne loggue pas en error
if ((err as Error).name !== 'AbortError') {
res.write(`event: error\ndata: ${JSON.stringify({ message: 'stream_failed' })}\n\n`);
}
} finally {
res.end();
}
}
}Ce qu'un staff engineer vérifie côté serveur, et qui complète les invariants Angular :
| Préoccupation | Côté Angular (ce chapitre) | Côté NestJS (le miroir) |
|---|---|---|
| Annulation | AbortController sur le fetch, câblé sur DestroyRef/stop() | req.on('close') → AbortController passé à messages.stream(..., { signal }) |
| Coût | Stop = ne plus afficher | Stop = ne plus payer — l'abort SDK coupe la génération de tokens |
| Idempotence | envoie generationId | dé-duplique sur generationId (file BullMQ : clé = generation id, pas de relance, on rejoue le buffer partiel) |
| Backpressure | rAF coalesce 50–100 tok/s en ~60 set/s | le res.write SSE applique la backpressure TCP ; sur charge, passer par une file |
| Config/secret | — | clé API injectée (forRootAsync), jamais new Anthropic() en champ |
| Edge | — | rate-limit + cost-guard (budget de tokens/user) avant d'ouvrir le stream |
Note version Anthropic : le SDK retry automatiquement les 429/5xx (
maxRetries), et l'on utilise le streaming pour tout ce qui a unmax_tokensélevé (sinon timeout HTTP). Lebudget_tokensduthinkinga été retiré sur les modèles flagship récents — on utilisethinking: { type: 'adaptive' }(+output_config: { effort }pour la profondeur). C'est l'analogue serveur de la règle Angular « ne raisonne pas sur le timing exact » : ici, ne raisonne pas sur le nombre exact de tokens, laisse le modèle s'auto-moduler.
🔁 Quand utiliser / éviter
effect() pour :
- Side effects pur (log, localStorage, analytics, intégration externe non-DOM).
- Synchronisation signal → ressource externe.
computed() pour :
- Dérivation pure d'un autre signal. Toujours préférer à un effect qui set.
afterNextRender pour :
- Init de lib DOM externe (Leaflet, Chart.js, ag-grid en mode imperatif).
- Mesure DOM one-shot (focus initial, scroll initial).
afterRender pour :
- Sync DOM continue (positionner un tooltip, gérer un scroll-spy).
- À utiliser avec parcimonie — coûteux car tourne à chaque CD.
Hooks classiques (ngOnInit, etc.) — restent utiles pour :
- Code legacy / lib externe qui attend ce contract.
ngOnInitquand vous voulez explicitement séparer "init avec inputs" du constructor.ngOnChanges(rare) pour réagir à un input non-signal.
DestroyRef.onDestroy plutôt que ngOnDestroy pour :
- Tout le nouveau code, surtout dans les services et fonctions utilitaires.
🧠 Approfondissement — l'ordering exact
L'ordre précis d'exécution lors d'un cycle de vie initial :
1. constructor() [synchrone, à la création]
├─ inject() résout les dépendances
├─ field initializers exécutés (incl. effect, signal, computed)
└─ corps du constructor
2. ngOnChanges(SimpleChanges) [si @Input ou input() change]
3. ngOnInit() [1x — après 1ère résolution des inputs]
4. ngDoCheck() [à CHAQUE cycle de CD]
5. ngAfterContentInit() [1x — après projection content]
6. ngAfterContentChecked() [à chaque CD après content]
7. ngAfterViewInit() [1x — après view rendered + child views]
8. ngAfterViewChecked() [à chaque CD après view]
── Synchronisation des signals (DANS le tick, AVANT le render DOM) ──
9. effect() callbacks [scheduled ; exécutés à la synchro
du framework, avant que le DOM ne soit peint]
── Render phase (le DOM est peint, hors CD) ──
10. afterRender phases : earlyRead → write → mixedReadWrite → read
afterNextRender (1x si attendu)⚠️ Piège d'ordering : ne confondez pas l'ordre du premier cycle (ci-dessus) avec celui des cycles suivants. Les
ngAfter*Checkedre-tournent à chaque CD ; leseffect()ne re-tournent que si une de leurs dépendances signal a changé ;afterRenderre-tourne à chaque render. Le seul invariant fiable : effects (synchro) → render → afterRender. Tout le reste est un détail du scheduler qui a déjà bougé entre 17, 18 et 19 — ne codez jamais contre cet ordre.
Effect timing — quand exactement ?
constructor() {
effect(() => {
console.log('effect run');
});
console.log('after effect creation');
}L'effect ne s'exécute pas dans le constructor. Il est schedulé et exécuté lors de la prochaine synchronisation du framework (pas un microtask que vous pouvez nommer précisément — ne vous reposez pas dessus). Le log "after effect creation" sort avant "effect run".
constructor() {
console.log(1);
effect(() => console.log(2));
console.log(3);
}
// log order : 1, 3, 2 (2 sort plus tard, à la synchronisation)Cette latence est généralement transparente, mais elle compte pour les tests : TestBed.tick() force la synchronisation (effects + afterRender) de façon déterministe. Ne raisonnez jamais sur l'ordre exact entre un effect et du code synchrone en production — c'est un détail d'implémentation du scheduler qui a déjà changé entre 17, 18 et 19.
🆚 Comparaison philosophique React vs Angular vs Solid
React useEffect Angular effect Solid createEffect
────────────── ────────────── ──────────────────
const [a, setA] = useState(); const a = signal(); const a = signal();
useEffect(() => { effect(() => { createEffect(() => {
doSomething(a); doSomething(a()); doSomething(a());
}, [a]); }); });
- deps explicites - tracking auto (signals) - tracking auto (signals)
- relancé après commit - schedulé (synchro CD) - synchronous batched
- peut set state - NE PEUT PAS set signal - peut set, batché
- cleanup en return - onCleanup callback - onCleanup callback
- pas glitch-free - glitch-free - glitch-free
- depend des deps maintenues - jamais oublier une dep - jamais oublier une depAngular se rapproche de Solid en philosophie (signals + computed + effect glitch-free), tout en gardant useEffect-style cleanup et un scheduler async.
🧰 Recipes avancées
Recipe : effect conditionnel
@Component({ /* ... */ })
export class FeatureCmp {
enabled = signal(false);
data = signal<any>(null);
constructor() {
effect(() => {
if (!this.enabled()) return; // tôt sorti, mais dépend toujours de enabled
const d = this.data(); // dépend aussi
send(d);
});
}
}Le tracking détecte this.enabled() ET this.data() (à condition d'arriver à la ligne). Si enabled est false, data n'est pas tracké. Quand enabled passe true, le re-run trackera les deux.
Recipe : effect debounced
import { effect } from '@angular/core';
constructor() {
let timeout: any;
effect(() => {
const value = this.search();
clearTimeout(timeout);
timeout = setTimeout(() => doSearch(value), 300);
});
}Pour quelque chose d'aussi simple, c'est ok. Pour des cas complexes, repassez par toObservable(signal).pipe(debounceTime(...)).
Recipe : DestroyRef hors composant
function createTimer(intervalMs: number) {
const destroyRef = inject(DestroyRef);
const id = setInterval(() => console.log('tick'), intervalMs);
destroyRef.onDestroy(() => clearInterval(id));
}
// dans un composant
constructor() {
createTimer(1000); // OK car constructor = injection context
}Recipe : afterRender pour scroll-spy
@Component({ /* ... */ })
export class ScrollSpyDirective {
private el = inject(ElementRef);
active = signal('');
constructor() {
afterRender({
earlyRead: () => {
const rect = this.el.nativeElement.getBoundingClientRect();
// mesure du scroll, des positions des sections
return computeActiveSection(rect);
},
write: (section) => {
// 'section' vient de earlyRead ; mettre à jour une classe CSS active, etc.
// (NE PAS set un signal lu dans le template ici — boucle de render)
},
});
}
}Recipe : effect avec cleanup pour WebSocket par-changement
@Component({ /* ... */ })
export class LiveCmp {
topic = signal('default');
constructor() {
effect((onCleanup) => {
const t = this.topic();
const ws = new WebSocket(`wss://api/${t}`);
ws.onmessage = m => handle(m);
onCleanup(() => ws.close()); // close à chaque change de topic
});
}
}Élégant : changer topic ferme l'ancien WS et ouvre le nouveau, le tout en 5 lignes.
📚 Décision arbre — quel hook utiliser ?
Vous voulez…
├── Initialiser un signal/computed/state local
│ → constructor (ou field initializer)
│
├── Faire un side effect sur un signal qui change
│ → effect()
│
├── Dériver une valeur d'autres signals
│ → computed() (JAMAIS effect)
│
├── État WRITABLE qui se réinitialise quand une source change
│ → linkedSignal() (Angular 19+, remplace l'ex-usage de allowSignalWrites)
│
├── Lire les inputs avant le render
│ → constructor pour input() signal, ngOnInit pour @Input legacy
│
├── Manipuler le DOM après le premier render
│ → afterNextRender
│
├── Synchroniser le DOM à chaque render
│ → afterRender (avec phases read/write)
│
├── Réagir à un changement de @Input legacy
│ → ngOnChanges OU passer en input() signal
│
├── Nettoyer une ressource au destroy
│ → DestroyRef.onDestroy (nouveau) ou ngOnDestroy (legacy)
│
├── Subscribe à un Observable avec cleanup auto
│ → takeUntilDestroyed(destroyRef) ou async pipe ou toSignal
│
└── Une opération HTTP au mount avec cancellation
→ resource() ou httpResource() (Angular 19+)🧪 Tests d'intégration plus poussés
Composer effects + signals + http
it('full integration : signal -> http -> signal', fakeAsync(() => {
const httpSpy = TestBed.inject(HttpClient);
spyOn(httpSpy, 'get').and.returnValue(of({ name: 'X' } as User));
TestBed.runInInjectionContext(() => {
const id = signal('1');
const user = toSignal(
toObservable(id).pipe(switchMap(i => httpSpy.get<User>(`/u/${i}`))),
);
TestBed.tick();
tick();
expect(user()).toEqual({ name: 'X' });
id.set('2');
TestBed.tick();
tick();
expect(httpSpy.get).toHaveBeenCalledTimes(2);
});
}));Vérifier qu'un effect ne boucle pas
it('does not loop', () => {
TestBed.runInInjectionContext(() => {
const a = signal(0);
let runs = 0;
effect(() => { a(); runs++; });
for (let i = 0; i < 10; i++) {
TestBed.tick();
}
expect(runs).toBeLessThan(2); // 1 seul run car a n'a pas changé
});
});🏛️ Architecture — où mettre vos effects ?
Dans un composant :
- Effects spécifiques au cycle de vie de ce composant.
- Sync DOM, log temporaire, manip d'élément local.
Dans un service providedIn: 'root' :
- Effects globaux (sync localStorage, analytics).
- ATTENTION : le service ne meurt jamais. Si l'effect tient une ressource (WS, timer), elle vit pour l'éternité. C'est rarement ce qu'on veut.
Dans un service providedIn: 'platform' :
- Cross-app singletons (rare).
Dans une fonction utilitaire :
- Doit être appelée depuis un injection context.
- Utilisez
runInInjectionContext(injector, () => effect(...))si vous l'appelez hors contexte.
Le piège du service root qui ne meurt jamais
Un effect() créé dans un service providedIn: 'root' est lié à la durée de vie de l'injector root, c'est-à-dire toute l'application. Si cet effect ouvre une ressource par run (timer, WS, listener), et qu'il re-run, l'ancien onCleanup ferme bien la ressource précédente — mais l'effect lui-même ne sera jamais détruit. Conséquences à surveiller :
| Symptôme | Cause | Fix |
|---|---|---|
| Connexion WS persistante après "logout" | effect WS dans un service root | mettre l'effect dans un service scope-é (route/feature) ou exposer un dispose() explicite |
| Listeners qui s'empilent au HMR en dev | effect root recréé sans destroy de l'ancien injector | normal en HMR ; vérifier qu'en prod un seul injector existe |
| Memory leak de closures | effect root capturant de gros objets | scinder l'état ; ne capturer que des signals |
Règle : un effect à durée de vie infinie ne doit faire que des side effects sans ressource (log, localStorage, analytics). Dès qu'il y a une ressource ouverte, posez-vous la question du scope.
🚦 Production — perf, observabilité, scale
Un staff engineer ne juge pas un effect sur sa correction fonctionnelle mais sur son coût agrégé et sa débogabilité.
Perf : le coût caché des effects et de afterRender
afterRendertourne à CHAQUE render de l'app entière, pas seulement de votre composant. DixafterRender"innocents" qui font chacun ungetBoundingClientRect= dix lectures de layout par frame. En liste virtualisée, c'est un budget de frame cramé. Profilez avec le Angular DevTools Profiler + l'onglet Performance (cherchez les Recalculate Style / Layout récurrents).- Coalescing ≠ gratuit : un effect coalescé ne re-run qu'une fois, mais s'il lit 8 signals et fait un
JSON.stringify, ce travail tourne à chaque changement de l'un des 8. Préférez uncomputedintermédiaire memoïzé en amont. - Zoneless (Angular 20) : sans Zone.js, seuls les changements de signals déclenchent la CD. Un effect qui set un signal "inutilement" (même valeur) ne déclenche rien grâce à l'égalité par défaut — mais un objet recréé à chaque fois (
{ ...x }) casse cette égalité et provoque des ticks fantômes. Utilisezsignal(value, { equal: myEqual })pour les valeurs structurelles.
Observabilité : déboguer un effect qui se relance trop
import { effect } from '@angular/core';
let lastRun = 0;
effect(() => {
const now = performance.now();
const dt = now - lastRun; lastRun = now;
const a = this.a(); const b = this.b();
if (dt < 16) console.warn('[effect] re-run < 1 frame', { dt, a, b });
// ... vrai side effect
});Pour savoir quel signal a réveillé un effect : Angular DevTools (≥ v17) expose le graphe de dépendances des signals. En dernier recours, instrumentez : enveloppez chaque signal suspect dans un wrapper qui log au read. Le symptôme classique "mon effect tourne 200x" est presque toujours (1) un objet recréé en dépendance, ou (2) un effect qui set un signal lu par lui-même.
Scale : SSR, hydration et effects
- Effects et SSR : par défaut, les effects ne s'exécutent pas côté serveur (pas de DOM, pas de scheduler de frame).
afterNextRender/afterRenderne s'exécutent jamais au serveur — c'est leur contrat, ce qui en fait l'endroit correct pour le code browser-only (window,localStorage, libs DOM). Ne mettez jamais d'accèswindowdans un constructor d'un composant SSR : ça crashe le rendu serveur. Mettez-le dansafterNextRender. - Hydration : pendant l'hydratation incrémentale, un composant peut être créé tardivement. Vos effects de "init au mount" se déclenchent alors au bon moment sans code spécial — c'est l'un des gains de vivre dans le constructor + effect plutôt que dans
ngOnInit.
🏋️ Exercices
Progression : implémenter → rendre production-grade → casser puis réparer. Faites-les dans l'ordre.
Exercice 1 — explicitEffect typé (implémenter)
Objectif : recréer le helper communautaire explicitEffect(deps, fn) qui ne tracke QUE les signals listés, ignorant ceux lus dans fn.
Indice/Solution : lisez les deps via untracked? Non — l'inverse : lisez les deps normalement (pour les tracker) et enveloppez le corps dans untracked(() => fn(values)). Signature : explicitEffect<T extends unknown[]>(deps: { [K in keyof T]: Signal<T[K]> }, fn: (values: T) => void). Le piège : passer les valeurs déjà lues à fn, sinon fn re-tracke. Testez qu'un signal lu dans fn mais absent de deps ne déclenche pas de re-run.
Exercice 2 — linkedSignal vs effect (réparer un anti-pattern)
Objectif : on vous donne un composant avec selectedTab = signal(0) et effect(() => { if (this.tabs().length <= this.selectedTab()) this.selectedTab.set(0); }). Ce code ne compile pas en Angular 19 (write dans effect). Réparez sans effect.
Indice/Solution : c'est l'archétype du linkedSignal. selectedTab = linkedSignal({ source: this.tabs, computation: (tabs, prev) => prev && prev.value < tabs.length ? prev.value : 0 }). La sélection reste settable par l'utilisateur, mais se borne automatiquement quand tabs rétrécit. Vérifiez le comportement quand on supprime l'onglet courant.
Exercice 3 — Stream d'agent avec coalescing (production-grade)
Objectif : implémenter le AgentChatService de la section IA, mais ajoutez : (a) un time-to-first-token mesuré, (b) un throttle qui bascule du flush par-frame vers un flush par-token quand le débit est < 5 tokens/s (pour que les réponses lentes paraissent réactives), (c) un timeout de 30 s qui abort si aucun token n'arrive.
Indice/Solution : TTFT = performance.now() au premier enqueue moins le now du send. Le throttle adaptatif : si l'intervalle entre tokens dépasse un seuil, flushez immédiatement au lieu d'attendre le rAF. Le timeout : setTimeout(() => this.controller?.abort(), 30_000), reset à chaque token, clearTimeout dans le finally. Attention à brancher tout le cleanup sur DestroyRef ET sur stop().
Exercice 4 — Casser puis réparer : la boucle de render (debug)
Objectif : écrivez délibérément un afterRender qui fait this.height.set(el.clientHeight) où height est lu dans le template. Observez l'erreur/boucle (CPU à 100 %, ou warning Angular). Puis réparez de deux façons distinctes et expliquez le tradeoff.
Indice/Solution : Fix A — déplacer la mesure one-shot dans afterNextRender (ne re-tourne pas → pas de boucle, mais ne suit pas les resize). Fix B — garder afterRender mais ne pas réinjecter dans le graphe : écrire directement le style DOM dans la phase write au lieu de set un signal. Tradeoff : A est déclaratif mais statique ; B suit chaque frame mais sort le state du graphe réactif (le template ne "voit" pas height). Le bon choix dépend de qui doit consommer la mesure.
Exercice 5 — Cleanup ordering (failure mode)
Objectif : un composant a un effect avec onCleanup, un DestroyRef.onDestroy, et un ngOnDestroy. Prédisez l'ordre d'exécution au destroy, vérifiez-le avec des logs, puis concevez un cas où mélanger les trois cause un bug (ex. ngOnDestroy lit une ressource déjà fermée par onCleanup).
Indice/Solution : les onCleanup d'effect et les callbacks DestroyRef.onDestroy se déclenchent à la destruction ; ngOnDestroy est appelé dans la séquence de destruction du composant. L'ordre n'est pas une API publique stable — c'est le point : ne créez jamais de dépendance d'ordre entre ces trois mécanismes. Le fix de design : une seule autorité de teardown par ressource. La leçon vaut plus que l'ordre exact.
Exercice 6 (bonus, hard) — effect SSR-safe + hydration
Objectif : un composant doit lire localStorage.getItem('theme') et l'appliquer au <html>. Faites-le crasher en SSR (accès localStorage au serveur), puis rendez-le SSR-safe et sans flash de thème (FOUC) à l'hydratation.
Indice/Solution : le crash vient d'un accès localStorage dans le constructor/effect côté serveur. SSR-safe : déplacez la lecture+application DOM dans afterNextRender (ne tourne qu'au browser). Le FOUC : il faut un script inline <head> qui applique le thème avant le bootstrap Angular (le composant ne peut pas être assez tôt). Discutez pourquoi isPlatformBrowser est un palliatif inférieur à afterNextRender ici (couplage explicite vs contrat de phase).
Exercice 7 (bonus, full-stack) — annulation de bout en bout, client ET serveur
Objectif : reliez le chat.stop() Angular au contrôleur SSE NestJS de la section IA. Prouvez par une mesure que cliquer Stop arrête la facturation des tokens, pas seulement l'affichage. Puis cassez-le : retirez le req.on('close') côté serveur et observez le usage.output_tokens continuer à grimper après le Stop.
Indice/Solution : côté Angular, stop() appelle AbortController.abort() → le fetch rejette AbortError → la connexion TCP se ferme. Côté NestJS, req.on('close') doit abort() un AbortController passé en { signal } à this.anthropic.messages.stream(...) ; sans lui, le SDK continue à consommer le stream Anthropic jusqu'à end_turn. Pour prouver l'arrêt : loggez (await stream.finalMessage()).usage.output_tokens dans les deux cas (avec et sans req.on('close')), Stop déclenché à ~mi-réponse. Bonus production : ajoutez l'idempotence — un generationId qui, sur retry, ne relance pas la génération mais rejoue le buffer partiel depuis une file BullMQ (clé = generation id, pas de double facturation).
🎤 En entretien
Q : Pourquoi Angular interdit-il d'écrire un signal depuis un effect(), et qu'a remplacé allowSignalWrites ? R : Parce qu'un effect qui set un signal est presque toujours soit une dérivation déguisée (→ computed), soit une boucle de réactivité. L'interdiction force la pureté du graphe pull. allowSignalWrites (échappatoire en 18) a été retiré en 19 ; le cas légitime "état writable qui se reset sur une source" est désormais couvert par linkedSignal.
Q : Quelle est la différence entre afterNextRender et ngAfterViewInit pour lire le DOM, et pourquoi l'un évite ExpressionChangedAfterItHasBeenCheckedError ? R : ngAfterViewInit tourne dans la passe de change detection : modifier un état lu dans le template y déclenche l'erreur en dev. afterNextRender s'exécute après le render, hors du cycle de CD, et seulement côté browser (jamais en SSR) — c'est l'endroit contractuellement sûr pour mesurer/manipuler le DOM et même set un signal sans casser l'invariant de la CD.
Q : Vous streamez des tokens LLM à 80/s dans une UI zoneless. Que casse une approche naïve signal.set par token, et comment le corrigez-vous ? R : 80 sets/s = 80 ticks de CD/s, chacun re-rendant la conversation si @for n'a pas de track stable. Fix : (1) coalescer hors graphe avec requestAnimationFrame → un seul set par frame (~60 Hz max), (2) buffer append-only + @for track id pour ne patcher que la dernière bulle, (3) OnPush. Bonus : un Stop câblé à AbortController qui annule client et serveur pour arrêter la facturation.
Q : Où mettez-vous un effect() qui ouvre un WebSocket, et quel est le risque dans un service providedIn: 'root' ? R : Le risque est que l'injector root ne meurt jamais : l'effect vit pour toute l'app et le WS aussi. Si le WS doit suivre un scope (une feature, une route, un user authentifié), placez l'effect dans un service scopé à ce niveau, ou exposez un dispose() explicite. Dans un composant, DestroyRef.onDestroy/onCleanup gèrent ça gratuitement. La règle : effect à vie infinie ⇒ aucun handle de ressource ouvert.
Q : Votre UI Angular a un bouton Stop sur un stream LLM. AbortController.abort() côté client suffit-il ? R : Non. Il coupe l'affichage et ferme le fetch, mais si le backend ne propage pas l'abort au fournisseur, la génération — et la facturation — continue. Le pattern complet est à deux bouts : abort() client → la connexion se ferme → côté NestJS, req.on('close') déclenche un AbortController passé en { signal } au SDK Anthropic (messages.stream(..., { signal })), ce qui stoppe réellement la génération de tokens. Le lifecycle Angular (DestroyRef/onCleanup) et le lifecycle de la requête serveur sont deux maillons de la même chaîne d'annulation. Bonus : un generationId pour l'idempotence sur retry, et un client LLM injecté en DI (forRootAsync) plutôt qu'un new Anthropic() en champ.
🔗 Liens
- https://angular.dev/api/core/effect
- https://angular.dev/api/core/linkedSignal — état writable dérivé (Angular 19)
- https://angular.dev/api/core/afterRender — signature à phases
- https://angular.dev/api/core/afterNextRender
- https://angular.dev/api/core/DestroyRef
- https://angular.dev/guide/signals#effects
- https://angular.dev/guide/signals/linked-signal
- https://angular.dev/guide/lifecycle — guide officiel lifecycle
- https://angular.dev/guide/zoneless — zoneless GA (Angular 20)
- https://angular.dev/guide/ssr — effects & afterRender côté serveur
- https://github.com/angular/angular/blob/main/CHANGELOG.md — évolution API
- https://blog.angular.dev/ — articles sur effects/signals
📖 Glossaire
- Lifecycle hooks : méthodes
ng*appelées par Angular à des moments précis. effect(): side effect réactif sur les signals lus à l'intérieur.computed(): signal dérivé pur, memoïzé, lazy.afterRender: callback à chaque render, avec phases.afterNextRender: callback une seule fois après le prochain render.DestroyRef: token DI exposantonDestroy(callback).takeUntilDestroyed: opérateur RxJS qui unsubscribe au destroy.onCleanup: callback passé à l'effect pour cleanup avant chaque re-run et au destroy.linkedSignal: signal writable dont la valeur dérive d'une source et se recalcule quand celle-ci change (Angular 19+).allowSignalWrites: ancienne optioneffect()(Angular 18) autorisant les writes ; retirée en Angular 19.- Injection context : moment où
inject()peut être appelé (constructor, field init, factory).
Récap final
Le cycle de vie Angular en 2026 est plus fonctionnel qu'objet : constructor + effect() + afterNextRender + DestroyRef.onDestroy couvrent 90% des cas. Les hooks ngOn* historiques restent là pour la compat. La règle d'or des effects : un effect qui modifie un signal est un bug — soit c'est un computed, soit c'est un event handler. Tout le reste découle. Et n'oubliez pas : afterNextRender est la seule porte d'entrée sûre pour le DOM, surtout en zoneless. Si vous démarrez un nouveau projet : oubliez ngOnInit et ngOnDestroy, vivez dans le constructor avec signals/computed/effect, utilisez DestroyRef pour le cleanup, et vous écrirez du code plus court, plus testable, et plus prévisible.