Skip to content

🅰️ Angular — du débutant au senior

Parcours complet pour maîtriser Angular moderne : Signals, zoneless, control flow, SSR/hydration. Versions 16 → 17 → 18 → 19 → 20.

🧭 Comment lire ce parcours (pour un dev expérimenté)

Tu viens de PHP/TypeScript avec 7 ans de métier. Tu n'apprends pas à programmer — tu apprends le modèle mental d'Angular moderne, qui a radicalement changé entre la v2-15 (modules + Zone.js + RxJS partout) et la v16-20 (standalone + signals + zoneless). Ce parcours est conçu pour t'amener directement à la version « 2025 » du framework, sans te faire perdre du temps sur les patterns hérités que tu ne dois connaître que pour migrer du legacy, pas pour en écrire.

Le grand changement de paradigme à intégrer dès maintenant :

Ancien Angular (v2 → v15)Angular moderne (v16 → v20)Pourquoi ça change tout
@NgModule partoutStandalone componentsPlus de boilerplate, tree-shaking par défaut, lazy-loading trivial
Zone.js patche tout l'event loopZoneless (provideZonelessChangeDetection)CD déclenchée par les signals, pas par un monkey-patch global → moins de CPU, stack traces propres
RxJS pour tout l'état localSignals + RxJS pour l'async/streamsRéactivité synchrone, granulaire, sans subscribe/unsubscribe à gérer
*ngIf / *ngFor (directives)@if / @for / @switch (control flow)Compilé, plus rapide, @for exige track, @defer natif
NgZone.run() / markForCheck()CD locale par signalTu ne « pousses » plus la détection, tu déclares des dépendances

Mental model en une phrase : Angular moderne est un graphe de dépendances réactif (signals) rendu par un compilateur agressif. Ton travail de senior : minimiser la surface de re-render, garder l'état dérivé (computed) plutôt que synchronisé, et ne descendre dans RxJS que pour ce qui est vraiment asynchrone/événementiel (réseau, websockets, debounce).

Comment un staff engineer raisonne sur ce framework (le sous-texte qu'aucun tuto ne dit) :

  1. La réactivité est un graphe, pas un flux. Un signal est un nœud-source ; un computed est un nœud-dérivé qui mémorise ses dépendances au moment de l'exécution (tracking dynamique, comme MobX/Solid, pas comme useMemo à deps explicites). Tu ne déclares jamais les dépendances : tu lis des signals dans le corps du computed, et Angular reconstruit le graphe à chaque évaluation. Conséquence subtile : une dépendance lue dans une branche if non prise n'est pas trackée ce tour-ci — le graphe est conditionnel.
  2. Push vs pull. Quand un signal change, Angular ne recalcule rien immédiatement : il marque les nœuds en aval comme « sales » (push de l'invalidation) et ne recalcule qu'à la lecture (pull de la valeur, glitch-free). C'est ce qui rend computed lazy et garantit qu'on ne voit jamais un état intermédiaire incohérent (pas de « glitch diamond » comme en RxJS naïf).
  3. Le compilateur fait la moitié du travail. @for, @if, @defer ne sont pas des directives runtime : ils sont compilés en instructions de rendu. Le template lui-même devient un reactive context — lire un signal dans le template crée une dépendance entre ce signal et la vue, d'où le re-render granulaire sans markForCheck.
  4. La frontière effect est sacrée. Tout ce qui est pur et dérivable va en computed. Tout ce qui sort du graphe (DOM impératif, log, localStorage, analytics) va en effect. Mettre de la dérivation dans un effect = tu recrées à la main ce que le graphe ferait gratuitement, et tu introduis un ordre d'exécution fragile.

Pièges de modèle mental les plus coûteux (failure modes) :

SymptômeCause racineCorrectif senior
Un computed ne se met jamais à jourTu as lu une valeur avant le tracking (ex. const v = sig(); return () => v) ou hors contexte réactifLis le signal dans le corps qui doit re-tracker
effect qui boucle / ExpressionChanged…L'effect écrit dans un signal qu'il lit (cycle)Casse le cycle ; pour écrire en réaction, effect(() => …, { allowSignalWrites }) est un code smell, préfère computed/linkedSignal
Re-render de toute la liste sur 1 changementtrack instable ($index, objet recréé)track item.id immuable
Rien ne re-render en zonelessÉtat affiché stocké hors signal (champ de classe muté)Toute donnée affichée = signal, ou AsyncPipe/markForCheck
Mémoire qui grimpeeffect/subscription non nettoyé hors contexte d'injectioneffect se nettoie seul dans un contexte DI ; sinon DestroyRef

Ordre de lecture recommandé

mermaid
graph LR
  A[N1 Foundations] --> B[N2 Reactive]
  B --> C[N3 Forms & Routing]
  C --> D[N4 State]
  D --> E[N5 Qualité & Perf]
  E --> F[N6 Production]
  B -.interop signals/RxJS.-> D
  A -.inject + DI.-> C
  1. Ne saute pas le Niveau 1. inject(), signals et control flow sont la grammaire de tout le reste — si tu les survoles, tout le N4 (state) et le N5 (perf) seront opaques.
  2. N2 (change detection) est le pivot senior. C'est là que se gagne ou se perd la perf. Lis-le deux fois.
  3. Forms / Routing (N3) sont autonomes : tu peux y aller dès que N1 est solide.
  4. State (N4) et Perf (N5) supposent N2 acquis (OnPush, zoneless, interop).
  5. Production (N6) en dernier : SSR/hydration/i18n ont des pièges qui n'ont de sens qu'une fois l'app comprise.

Repères de version (ce qui est stable quand)

FeatureIntroduitStable / recommandé
Standalone componentsv14 (dev preview)v15+ (défaut depuis v17 via ng new)
Signals (signal/computed/effect)v16 (preview)v17 stable, raffiné v18/v19
Nouveau control flow @if/@for/@deferv17v17+ stable
input() / output() signal-basedv17.1 / v17.3v18+
model() (two-way signal)v17.2v18+
Zoneless change detectionv18 (experimental)v20 (developer preview avancé) — production-viable avec tests
Hydration non-destructive + event replayv16 / v18v18+
@let dans les templatesv18.1v18.1+
linkedSignal / resource() / httpResourcev19v19+ (preview → stabilisation v20)

⚠️ Si tu vois encore NgModule, *ngFor, BrowserModule ou new Anthropic()-style instanciation directe dans des tutos : c'est du legacy. Tu dois savoir le lire et le migrer (N1-05), pas l'écrire.

Niveau 1 — Foundations

Objectif : la grammaire d'Angular moderne. À la fin, tu écris des composants standalone réactifs sans toucher à @NgModule.

Ce qu'un senior retient : inject() remplace l'injection par constructeur et débloque les injection functions (ex. injectQueryParams()). Un computed est lazy + mémoïsé : il ne recalcule que si une dépendance lue change et que quelqu'un le lit. Un effect n'est pas un endroit pour dériver de l'état — c'est un pont vers le monde extérieur (DOM, logs, localStorage). Mettre de la logique métier dans un effect est le bug n°1 des débutants signals.

Niveau 2 — Reactive layer

Objectif : savoir QUAND un signal, QUAND un Observable, et comprendre la change detection assez profondément pour la dompter (OnPush → signals → zoneless).

Arbre de décision Signal vs RxJS :

BesoinOutilRaison
État synchrone (compteur, filtre, sélection)signal / computedPas de subscription, pas de fuite, granulaire
Async unique (un fetch)resource() / httpResource (v19+) ou toSignal(http$)Gère loading/error/value en signal
Flux d'événements (clavier, websocket, scroll)RxJS (fromEvent, Subject)Opérateurs temporels (debounceTime, switchMap)
Annulation/race (typeahead)RxJS switchMapCancellation propre des requêtes en vol
Combiner état + fluxtoSignal(obs$) / toObservable(sig)Interop bidirectionnelle

Niveau 3 — Forms & Routing

Objectif : formulaires typés (FormGroup<T>), validation async sans race, routing lazy avec withComponentInputBinding.

Ce qu'un senior retient : les reactive forms sont typés depuis v14 — n'utilise jamais FormControl non typé. Les guards/resolvers sont des fonctions (CanActivateFn), plus des classes. withComponentInputBinding() mappe les params de route directement sur des input() du composant — fini ActivatedRoute.snapshot partout.

Niveau 4 — State management

Objectif : choisir l'échelle de state qui convient. 90 % des apps n'ont pas besoin de NgRx ; un Signal Store ou un service à signals suffit.

Échelle de décision (du plus simple au plus lourd) :

ÉchelleSolutionQuand
Composant localsignal dans le composantÉtat UI éphémère
Feature partagéeService @Injectable + signals (providedIn ou route-scoped)Le défaut moderne pour 80 % des cas
Feature avec logique richeNgRx Signal Store (signalStore, withState, withMethods, withComputed)Store réactif sans boilerplate Redux
App très large, audit/temps-voyage, équipes multiplesNgRx classique (actions/reducers/effects)Traçabilité event-sourcing, devtools

Niveau 5 — Qualité & Performance

Objectif : mesurer avant d'optimiser, supprimer le re-render inutile, et garder un bundle initial maigre via @defer.

Ce qu'un senior retient : profile avec Angular DevTools (onglet Profiler) avant de toucher quoi que ce soit. @for sans track correct détruit/recrée le DOM → c'est la cause perf n°1 sur les listes. @defer (on viewport) est ton meilleur levier pour le LCP/TBT. trackBy n'existe plus en @for : c'est track item.id directement.

Niveau 6 — Production

Objectif : SSR avec hydration non-destructive + event replay, PWA, déploiement, i18n — les sujets où « ça marche en local » ne suffit pas.

Ce qu'un senior retient : provideClientHydration(withEventReplay()) rejoue les clics survenus avant que le JS soit hydraté — gain UX énorme sans effort. Le piège SSR classique : accéder à window/document/localStorage au top-level d'un composant → crash serveur. Garde-toi avec afterNextRender() (v17+) ou isPlatformBrowser.


🤖 Construire des UI d'agents IA en Angular (section senior)

C'est ton cas d'usage réel : un front Angular qui consomme un agent IA servi par un backend NestJS (streaming de tokens Claude, trace d'outils, annulation). Voici les patterns que tu réutiliseras dans presque tous tes projets IA. Les modèles Anthropic actuels : claude-opus-4-8 (flagship), claude-sonnet-4-6, claude-haiku-4-5.

1. Streaming de tokens via SSE + signals (zoneless-ready)

L'erreur de débutant est de faire messages.push(token) puis de forcer la CD. En zoneless, rien ne re-render si tu n'utilises pas de signal, et même avec un signal tu veux coalescer les updates pour ne pas re-rendre à chaque token (60+ events/s).

ts
import { Injectable, signal } from '@angular/core';

interface ChatMessage {
  readonly id: string;
  readonly role: 'user' | 'assistant';
  text: string;       // muté pendant le stream, puis figé
  streaming: boolean;
}

@Injectable({ providedIn: 'root' })
export class ChatStreamService {
  // Buffer append-only : on ne remplace jamais le tableau entier inutilement
  readonly messages = signal<ChatMessage[]>([]);
  private controller: AbortController | null = null;

  async send(prompt: string): Promise<void> {
    this.controller?.abort();              // annule le stream précédent (race-safe)
    this.controller = new AbortController();

    const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', text: prompt, streaming: false };
    const aiMsg: ChatMessage = { id: crypto.randomUUID(), role: 'assistant', text: '', streaming: true };
    this.messages.update(m => [...m, userMsg, aiMsg]);

    const res = await fetch('/api/chat/stream', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Idempotency-Key': aiMsg.id },
      body: JSON.stringify({ prompt }),
      signal: this.controller.signal,      // le serveur DOIT aussi annuler (AbortController côté NestJS)
    });

    if (!res.ok || !res.body) {
      this.fail(aiMsg.id, `HTTP ${res.status}`);
      return;
    }

    const reader = res.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';        // accumule les octets : une frame SSE peut être coupée entre deux read()
    let pending = '';       // tokens en attente de flush rAF
    let frame = 0;

    const flush = () => {
      const delta = pending;
      pending = '';
      frame = 0;
      this.messages.update(list =>
        list.map(m => (m.id === aiMsg.id ? { ...m, text: m.text + delta } : m)),
      );
    };

    try {
      for (;;) {
        const { value, done } = await reader.read();
        if (done) break;
        buffer += decoder.decode(value, { stream: true });

        // On ne consomme QUE les frames complètes ("…\n\n"), on garde le reste en buffer.
        let sep: number;
        while ((sep = buffer.indexOf('\n\n')) !== -1) {
          const frameText = buffer.slice(0, sep);
          buffer = buffer.slice(sep + 2);
          for (const line of frameText.split('\n')) {
            if (!line.startsWith('data:')) continue;     // ignore les commentaires ":" et "event:"
            const payload = line.slice(5).trim();
            if (payload === '[DONE]') continue;
            try {
              const evt = JSON.parse(payload);
              if (evt.type === 'text_delta') pending += evt.text;
            } catch {
              /* frame partielle/corrompue : on ignore, la suite arrivera */
            }
          }
        }
        // rAF-coalescing : au plus un re-render par frame, pas un par token
        if (pending && !frame) frame = requestAnimationFrame(flush);
      }
    } catch (err) {
      // AbortError = Stop volontaire, pas une erreur à afficher
      if ((err as Error).name !== 'AbortError') this.fail(aiMsg.id, (err as Error).message);
    } finally {
      if (frame) cancelAnimationFrame(frame);
      flush();
      this.messages.update(list => list.map(m => (m.id === aiMsg.id ? { ...m, streaming: false } : m)));
    }
  }

  private fail(id: string, reason: string): void {
    this.messages.update(list =>
      list.map(m => (m.id === id ? { ...m, streaming: false, text: m.text || `⚠️ ${reason}` } : m)),
    );
  }

  stop(): void {
    this.controller?.abort();              // annule client ET déclenche la fin de stream serveur
  }
}

Pourquoi le requestAnimationFrame : sous zoneless, chaque signal.update() schedule une CD. À 80 tokens/s, c'est 80 renders/s du DOM markdown → jank. En coalesçant sur la frame, tu plafonnes à ~60 renders/s max, et le navigateur peut même en sauter (il ne tire la frame que quand l'onglet est visible — bonus batterie). C'est le pattern « append-only buffer + rAF flush » que tout staff engineer attend de voir.

Le bug que 90 % des implémentations contiennent (et que l'intervieweur cherche) : découper le flux read() par read() et faire chunk.split('\n') est faux. ReadableStream te livre des octets, pas des messages : une frame SSE data: {...}\n\n peut être coupée en plein milieu entre deux read(). Si tu JSON.parse une demi-frame, tu crashes ou tu perds des tokens. Le correctif (dans le code ci-dessus) : un buffer persistant entre les reads, et on ne consomme que les frames terminées par \n\n, en gardant le reliquat. C'est exactement la différence entre un parseur de protocole correct et un toy. Note aussi le TextDecoder({ stream: true }) : indispensable pour ne pas couper un caractère UTF-8 multi-octets (un emoji, un accent) à la frontière d'un chunk.

Tradeoffs SSE vs WebSocket vs fetch-stream pour du LLM :

TransportStreaming tokenAnnulationReconnexion autoQuand
EventSource (SSE natif)❌ (pas de body POST, pas d'AbortController propre)✅ (built-in)Flux read-only sans prompt dans le body
fetch + ReadableStream (ci-dessus)AbortController❌ (à coder)Le défaut LLM : POST du prompt + Stop fiable
WebSocket✅ bidirectionnelclose()❌ (à coder)Agent multi-tours interactif, outils côté client, voix

Pour un chat Claude classique, fetch + ReadableStream gagne : tu POST le prompt, tu obtiens un AbortController qui annule vraiment la requête réseau (et donc, en cascade, la génération serveur). EventSource ne sait pas POSTer ni s'annuler proprement.

2. Trace d'outils (tool-use) en discriminated union

Quand l'agent appelle des outils (le agentic loop tourne côté NestJS), le front affiche une timeline. Modélise-la en union discriminée pour que TypeScript force l'exhaustivité du rendu :

ts
type ToolStepStatus = 'pending' | 'running' | 'streaming' | 'done' | 'error';

interface ToolStep {
  readonly id: string;
  readonly tool: string;               // ex. 'search_docs', 'run_sql'
  status: ToolStepStatus;
  input?: unknown;
  output?: unknown;
  error?: string;
  readonly startedAt: number;
}
ts
import { Component, computed, input } from '@angular/core';

@Component({
  selector: 'app-tool-timeline',
  template: `
    <ol class="trace">
      @for (step of steps(); track step.id) {
        <li [class]="step.status">
          <span class="tool">{{ step.tool }}</span>
          @switch (step.status) {
            @case ('pending')   { <span>en file…</span> }
            @case ('running')   { <span class="spin">exécution…</span> }
            @case ('streaming') { <span class="spin">streaming…</span> }
            @case ('done')      { <pre>{{ step.output | json }}</pre> }
            @case ('error')     { <span class="err">{{ step.error }}</span> }
          }
        </li>
      }
    </ol>
  `,
})
export class ToolTimeline {
  readonly steps = input.required<ToolStep[]>();
  readonly running = computed(() => this.steps().some(s => s.status === 'running' || s.status === 'streaming'));
}

Note build VitePress : toute interpolation à double accolade ci-dessus vit dans un bloc ```ts fencé — jamais en prose ni en inline code. C'est la règle qui empêche Vue de tenter d'évaluer une expression Angular comme du JavaScript pendant docs:build.

3. Bouton Stop = annulation client et serveur

Un Stop qui n'annule que le rendu client mais laisse le backend brûler des tokens Claude est un bug de coût. Le flux complet : AbortController côté Angular → signal abort sur fetch → le serveur NestJS reçoit req.on('close') → propage à l'AbortController passé au SDK Anthropic (stream({ signal })). Tu paies alors uniquement les tokens déjà générés.

4. Rendu markdown sûr

Les réponses LLM sont du markdown. Rends-le avec une lib (marked) puis sanitize : ne fais jamais innerHTML brut sur une sortie modèle.

ts
import { inject } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { marked } from 'marked';

const sanitizer = inject(DomSanitizer);
function renderMarkdown(md: string): SafeHtml {
  const html = marked.parse(md, { async: false }) as string;
  return sanitizer.bypassSecurityTrustHtml(html); // marked échappe déjà; durcis avec DOMPurify si entrée non maîtrisée
}

Pour de l'input réellement non maîtrisé (multi-tenant), ajoute DOMPurify entre marked et bypassSecurityTrustHtmlbypass seul désactive la protection d'Angular.

5. Optimistic steps & cohérence avec le backend

Affiche l'étape « tool running » de façon optimiste dès que l'event SSE tool_use arrive, puis réconcilie sur l'event tool_result. Si le serveur renvoie une erreur, fais transitionner l'étape vers error (pas une suppression) pour garder l'historique d'audit visible.


🏋️ Exercices

Progression : implémenter → rendre production-grade → casser puis réparer. Chaque exercice s'appuie sur les chapitres ci-dessus.

Exo 1 — computed pur vs effect (Niveau 1-2)

Objectif : prouver que tu sais dériver de l'état sans effect. Construis un panier : signal<Item[]>, un computed total, un computed discountedTotal (≥100€ → -10 %). Ajoute un compteur de recalculs. Vérifie qu'ajouter un item qui ne change pas le palier de remise ne recalcule pas inutilement les computed non concernés. Indice/Solution : mémoïsation lazy — un computed ne recalcule que si une dépendance lue change ET qu'il est consommé. Un effect ici serait une faute : il introduirait un état dérivé synchronisé, source de désync.

Exo 2 — Typeahead anti-race (Niveau 2-3)

Objectif : combiner signal + RxJS sans race condition. Champ de recherche : toObservable(querySignal)debounceTime(250)distinctUntilChanged()switchMap(http)toSignal(). Tape vite, vérifie qu'une réponse lente d'une requête abandonnée n'écrase jamais une plus récente. Indice/Solution : switchMap annule la requête précédente (différence cruciale avec mergeMap). Sans lui, la réponse n°1 (lente) peut arriver après la n°2 → résultat incohérent.

Exo 3 — Liste 10 000 lignes en zoneless (Niveau 2-5)

Objectif : rendre une grosse liste fluide. Active provideZonelessChangeDetection(). Affiche 10 000 lignes avec @for (… track row.id). Mesure le temps de re-render au Profiler quand tu modifies UNE ligne. Puis vire le track correct (mets track $index) et observe l'explosion de DOM recréé. Indice/Solution : track row.id permet à Angular de réutiliser les nœuds DOM ; track $index les détruit/recrée dès qu'un élément est inséré au début. Couple avec OnPush/signals pour isoler le re-render à la ligne modifiée.

Exo 4 — Chat IA streaming (intégration NestJS) ⭐

Objectif : un chat Claude qui stream, avec Stop fonctionnel. Implémente le ChatStreamService ci-dessus contre un endpoint SSE NestJS qui relaie claude-haiku-4-5. Affiche le markdown sanitizé. Le bouton Stop doit abort fetch et le backend doit cesser de consommer des tokens. Indice/Solution : AbortController.signal sur fetch ; côté NestJS, req.on('close') → propage l'abort au stream SDK Anthropic. rAF-coalesce le rendu des tokens.

Exo 5 — Casser puis réparer l'hydration SSR (Niveau 6) 🔥

Objectif : diagnostiquer un mismatch d'hydration. Active SSR + provideClientHydration(). Introduis un bug : un template qui interpole Math.random() (valeur différente serveur/client) ou un accès localStorage au constructeur :

html
<!-- BUG volontaire : valeur non déterministe → mismatch d'hydration -->
<span>{{ Math.random() }}</span>

Observe le warning NG0500 / le crash SSR. Répare avec afterNextRender() et une valeur stable. Indice/Solution : l'hydration exige un DOM identique serveur↔client. Toute valeur non déterministe ou tout accès navigateur au mauvais moment casse. afterNextRender() (v17+) garantit l'exécution côté client uniquement, après hydration.

Exo 6 — Signal Store avec annulation de job IA (Niveau 4) 🔥

Objectif : state machine d'un job d'agent. Modélise avec signalStore un job IA : idle | queued | running | streaming | done | error. Expose start(), stop() (AbortController), retry() avec back-off. Garantis l'idempotence : relancer avec la même generationId ne crée pas un doublon. Indice/Solution : withState pour la machine, withMethods pour les transitions, withComputed pour canRetry. Clé d'idempotence = generationId passée en header Idempotency-Key au backend (BullMQ côté NestJS dédoublonne dessus). Modélise les transitions interdites explicitement (done → running doit lever, pas muter silencieusement) — une vraie state machine refuse les transitions illégales.

Exo 7 — Casser le parseur SSE (Niveau 5-6) 🔥

Objectif : reproduire et corriger le bug de frontière de chunk. Mocke un serveur qui émet la frame data: {"type":"text_delta","text":"café ☕"}\n\n en deux read() : d'abord data: {"type":"text_del, puis le reste. Vérifie que la version naïve (chunk.split('\n') sans buffer) crashe ou perd le token, puis que la version bufferisée le reconstruit intact. Bonus : coupe un emoji multi-octets à la frontière et prouve que TextDecoder({ stream: true }) le recolle (vs un new TextDecoder() par chunk qui produit ). Indice/Solution : le ReadableStream livre des octets, pas des messages. Buffer persistant + découpe sur \n\n + decoder en mode stream. C'est LE piège que cherche un intervieweur senior sur le streaming LLM.

Exo 8 — linkedSignal : reset dérivé sans effect (Niveau 2-4) ⭐

Objectif : remplacer un effect de synchronisation par un linkedSignal (v19+). Une liste paginée : items (source) et un selectedId éditable par l'utilisateur. Quand items change (nouvelle page), selectedId doit se réinitialiser au premier item — mais rester librement modifiable entre deux changements. La tentation débutante est un effect(() => selectedId.set(items()[0]?.id)) (anti-pattern : allowSignalWrites, ordre fragile). Fais-le avec linkedSignal. Indice/Solution : linkedSignal({ source: items, computation: list => list[0]?.id }) donne un signal writable qui se recalcule quand source change mais accepte les set() manuels entre-temps. C'est exactement le cas « état dérivé mais éditable » que ni computed (read-only) ni effect (effet de bord) ne couvrent proprement.


🎤 En entretien

Q : Différence entre computed et effect ? Quand l'un est-il un anti-pattern ? R : computed dérive une valeur pure, lazy et mémoïsée, lisible dans le template. effect produit un effet de bord (DOM, log, storage) et tourne après chaque changement de ses dépendances. Utiliser un effect pour calculer/synchroniser de l'état est un anti-pattern : ça crée un état dérivé désynchronisable et des boucles ; c'est presque toujours un computed qu'il fallait.

Q : Pourquoi le zoneless, et qu'est-ce que ça impose au code ? R : Zone.js patche tout l'event loop pour déclencher la CD globalement — coûteux et opaque. Le zoneless déclenche la CD via les notifications de signals : moins de CPU, stack traces propres, perf prévisible. Ça impose que tout état affiché passe par des signals (ou markForCheck/AsyncPipe) ; un setTimeout qui mute un champ non-signal ne re-render plus.

Q : OnPush vs signals vs zoneless — c'est redondant ? R : Non, c'est un continuum. OnPush limite la CD à : input changé (référence), event du composant, ou AsyncPipe. Les composants à signals re-render de façon granulaire sur lecture de signal, indépendamment d'OnPush. Le zoneless supprime le déclencheur global Zone.js : la CD ne part plus que des signals/notifs. En pratique senior : OnPush partout aujourd'hui, signals comme source de vérité, zoneless quand l'app est entièrement signal-driven et testée.

Q : Comment streamer une réponse LLM dans une UI Angular sans tuer la perf ? R : SSE/ReadableStreamgetReader() + TextDecoder, append dans un buffer signal coalescé sur requestAnimationFrame (1 render/frame, pas 1/token). Bouton Stop câblé sur AbortController qui annule fetch et signale au backend d'arrêter de consommer des tokens. Markdown rendu via marked + sanitization (DomSanitizer/DOMPurify), jamais innerHTML brut sur sortie modèle. Piège technique : bufferise le flux et ne parse que les frames complètes (\n\n) — un read() ne livre pas un message entier.

Q : computed vs linkedSignal vs effect — comment tu choisis ? R : computed = dérivé pur read-only (le template lit, personne ne set). linkedSignal (v19+) = dérivé writable qui se réinitialise quand sa source change mais accepte des écritures manuelles entre-temps (sélection qui reset à chaque page, mais éditable). effect = uniquement pour les effets de bord sortants (DOM impératif, storage, analytics). Si tu te surprends à effect(() => sig.set(...)), c'est presque toujours un computed ou un linkedSignal déguisé.

Q : Comment fonctionne le tracking de dépendances d'un computed ? Quelle conséquence piège ? R : Tracking dynamique au runtime : Angular instrumente les lectures de signals pendant l'exécution du corps. Les dépendances sont donc conditionnelles — un signal lu seulement dans une branche if non prise n'est pas une dépendance ce tour-ci. Conséquence : si tu lis la valeur d'un signal avant le contexte réactif (ex. capture dans une closure passée ailleurs), tu perds le tracking et le computed paraît « gelé ». Le modèle est push-pull glitch-free : un changement invalide (push) en aval, mais ne recalcule qu'à la lecture (pull), ce qui élimine les états intermédiaires incohérents.

Q : Tu actives le zoneless sur une app existante — quel est ton plan de migration et où ça casse ? R : Prérequis : OnPush partout, état affiché en signals (ou AsyncPipe). Ça casse là où la vue dépend d'une mutation hors signal déclenchée par une API non-Angular : setTimeout/setInterval qui mute un champ de classe, callbacks de libs tierces (maps, charts, WebSocket brut), addEventListener manuel. Sans Zone.js, ces mutations ne notifient plus la CD. Correctif : passer ces états en signals, ou ChangeDetectorRef.markForCheck() au point d'intégration. Stratégie : activer provideZonelessChangeDetection() en preview, lancer la suite e2e, et chasser les vues « mortes » (qui ne se mettent plus à jour) — ce sont tes points de couplage à Zone.js.

Bibliothèque tech perso — Achref