Skip to content

Effects, lifecycle hooks, afterRender — le cycle de vie en 2026

TL;DReffect() n'est pas l'équivalent de useEffect de 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. afterRender et afterNextRender (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 de ExpressionChangedAfterItHasBeenCheckedError. DestroyRef + takeUntilDestroyed remplacent le pattern destroy$ = new Subject() historique. Anti-pattern critique : effect() qui modifie un autre signal — interdit par défaut ; l'option allowSignalWrites a été retirée en Angular 19 (utilisez computed, linkedSignal, ou un event handler).

Le piège mental n°1 (ex-React) : un effect() n'est pas fait pour synchroniser deux états (ab). Ça, c'est computed/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 ici

Analogie 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 :

AspectRéalité (Angular 19/20)
Premier runLazy : 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-runSchedulé quand une dépendance change, exécuté pendant la phase de synchronisation de la CD, avant le render.
CoalescingPlusieurs 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 testTestBed.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

ts
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

ts
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 :

  1. Marque l'effect dirty.
  2. À la prochaine synchronisation du framework, le scheduler appelle le onCleanup du run précédent.
  3. Puis exécute le nouveau callback.

afterRender / afterNextRender

ts
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 earlyReadwritemixedReadWriteread 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 phase mixedReadWrite. La forme à phases est celle à privilégier dès qu'on lit et écrit le DOM.

Signals dans constructor vs ngOnInit

ts
@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

ts
@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)

ts
@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

ts
@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

ts
@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

ts
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

VersionApport sur les effects / lifecycle
16DestroyRef, takeUntilDestroyed. afterRender, afterNextRender. effect() (preview).
17effect() stable. Signals stable. mutate() retiré.
17.3Signal inputs / queries / outputs en developer preview. viewChild, contentChild signal-based.
18allowSignalWrites 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.1TestBed.flushEffects() déprécié au profit de TestBed.tick().
19allowSignalWrites retiré de l'API (plus une option du tout). resource() stable (preview→stable selon canal). linkedSignal(). httpResource(). Effects rattachés à la synchronisation du framework.
20Zoneless 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)

ts
// 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 :

IntentionMauvais (effect qui set)Bon
a est purement dérivé de beffect(() => 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 + flaga = linkedSignal(() => this.b()*2)
Réagir à un event utilisateur en mutant l'étateffect sur un signal d'eventevent handler explicite (click)="..."
Synchroniser deux états bidirectionnellementdeux 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".

ts
// 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

  1. effect qui set un autre signal (boucle infinie) — l'erreur la plus fréquente. Interdit par défaut depuis Angular 18, sans échappatoire depuis 19 (allowSignalWrites retiré). Toujours préférer computed ; 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 — un set dans un .then/callback async déclenché plus tard est légal.

  2. effect() créé hors injection contexteffect() doit être créé dans un constructor, field initializer, ou runInInjectionContext. Sinon : runtime error. Pour les services, créez les effects dans le constructor du service.

  3. Lecture DOM dans ngAfterViewInit — souvent provoque ExpressionChangedAfterItHasBeenCheckedError. Utilisez afterNextRender.

  4. afterRender dans une boucle de calcul de layoutafterRender se ré-exécute à chaque CD. Si vous mesurez puis modifiez un signal lu dans le template, vous boucle. Préférez afterNextRender pour les one-shots.

  5. Oublier onCleanupeffect((onCleanup) => { const id = setTimeout(...); onCleanup(() => clearTimeout(id)); }). Sans onCleanup, vos timers s'accumulent à chaque re-run.

  6. takeUntilDestroyed() sans arg hors injection context — pareil que pour effect. Passez explicitement takeUntilDestroyed(this.destroyRef).

  7. Mélanger ngOnDestroy et DestroyRef.onDestroy — choisissez un style. Mélanger rend la teardown order opaque. Préférence : DestroyRef.onDestroy (composable, fonctionnel) pour le nouveau code.

  8. 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 un computed, un effect, ou ngOnInit (et au-delà).

  9. afterRender qui modifie un signal lu dans le template — boucle de render. afterRender doit ÊTRE le point terminal de la CD, pas une source de nouveau state.

  10. ngDoCheck utilisé comme "useEffect with no deps"ngDoCheck tourne à 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.

  11. effect qui fait du HTTP — fonctionnel mais le HTTP n'est pas cancellable et peut s'empiler. Préférez resource() ou toSignal(http$).

🧪 Testing — fakeAsync, TestBed.runInInjectionContext, TestBed.tick

Effect — TestBed.tick() (Angular 19+)

ts
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 par TestBed.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

ts
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

ts
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

ts
// 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 : useEffect peut modifier le state (autorisé mais cause re-render).
  • Angular : effect ne 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.

ts
// 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>;
    });
  }
}
ts
// 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);
  }
}
ts
// 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 [];
    }
  }
}
ts
// 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 un fetch async. Le .then qui appelle this.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). Un set dans 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.push mute 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éplacerait push dans 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 bas

Quatre invariants de senior :

  1. Buffer append-only : on n'édite jamais un message passé, on pousse. Ça rend le rendu trivialement memoïzable et @for avec track id ne re-render que la dernière bulle.
  2. Coalescing par requestAnimationFrame : un LLM peut émettre 50–100 tokens/s. Un signal.set par 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.
  3. Annulation à deux bouts : AbortController annule le fetch client et propage l'abort au serveur (qui doit lui-même abort() l'appel au SDK Anthropic — voir le pendant NestJS). Sans ça, vous payez des tokens pour une réponse que personne ne lit.
  4. 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

ts
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

ts
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. bypassSecurityTrustHtml sur du contenu d'agent = faille XSS directe. On sanitize, on ne bypass jamais.
  • track msg.id dans @for : sans clé stable, chaque token re-crée tout le DOM de la conversation. Avec, seule la dernière bulle se patche.
  • afterRender lit lastLen() 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() → le fetch rejette AbortError ET le serveur voit la connexion fermée. Côté NestJS, branchez le req.on('close') sur un AbortController passé 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 faire effect(() => 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.

ts
// 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 {}
ts
// 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éoccupationCôté Angular (ce chapitre)Côté NestJS (le miroir)
AnnulationAbortController sur le fetch, câblé sur DestroyRef/stop()req.on('close')AbortController passé à messages.stream(..., { signal })
CoûtStop = ne plus afficherStop = ne plus payer — l'abort SDK coupe la génération de tokens
Idempotenceenvoie generationIddé-duplique sur generationId (file BullMQ : clé = generation id, pas de relance, on rejoue le buffer partiel)
BackpressurerAF coalesce 50–100 tok/s en ~60 set/sle res.write SSE applique la backpressure TCP ; sur charge, passer par une file
Config/secretclé API injectée (forRootAsync), jamais new Anthropic() en champ
Edgerate-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 un max_tokens élevé (sinon timeout HTTP). Le budget_tokens du thinking a été retiré sur les modèles flagship récents — on utilise thinking: { 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.
  • ngOnInit quand 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*Checked re-tournent à chaque CD ; les effect() ne re-tournent que si une de leurs dépendances signal a changé ; afterRender re-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 ?

ts
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".

ts
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 dep

Angular 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

ts
@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

ts
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

ts
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

ts
@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

ts
@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

ts
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

ts
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ômeCauseFix
Connexion WS persistante après "logout"effect WS dans un service rootmettre l'effect dans un service scope-é (route/feature) ou exposer un dispose() explicite
Listeners qui s'empilent au HMR en deveffect root recréé sans destroy de l'ancien injectornormal en HMR ; vérifier qu'en prod un seul injector existe
Memory leak de closureseffect root capturant de gros objetsscinder 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

  • afterRender tourne à CHAQUE render de l'app entière, pas seulement de votre composant. Dix afterRender "innocents" qui font chacun un getBoundingClientRect = 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 un computed intermé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. Utilisez signal(value, { equal: myEqual }) pour les valeurs structurelles.

Observabilité : déboguer un effect qui se relance trop

ts
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/afterRender ne 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ès window dans un constructor d'un composant SSR : ça crashe le rendu serveur. Mettez-le dans afterNextRender.
  • 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)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

📖 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 exposant onDestroy(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 option effect() (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.

Bibliothèque tech perso — Achref