🅰️ 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 partout | Standalone components | Plus de boilerplate, tree-shaking par défaut, lazy-loading trivial |
| Zone.js patche tout l'event loop | Zoneless (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 local | Signals + RxJS pour l'async/streams | Ré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 signal | Tu 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) :
- La réactivité est un graphe, pas un flux. Un
signalest un nœud-source ; uncomputedest un nœud-dérivé qui mémorise ses dépendances au moment de l'exécution (tracking dynamique, comme MobX/Solid, pas commeuseMemoà deps explicites). Tu ne déclares jamais les dépendances : tu lis des signals dans le corps ducomputed, et Angular reconstruit le graphe à chaque évaluation. Conséquence subtile : une dépendance lue dans une brancheifnon prise n'est pas trackée ce tour-ci — le graphe est conditionnel. - 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
computedlazy et garantit qu'on ne voit jamais un état intermédiaire incohérent (pas de « glitch diamond » comme en RxJS naïf). - Le compilateur fait la moitié du travail.
@for,@if,@deferne 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 sansmarkForCheck. - La frontière
effectest sacrée. Tout ce qui est pur et dérivable va encomputed. Tout ce qui sort du graphe (DOM impératif, log,localStorage, analytics) va eneffect. Mettre de la dérivation dans uneffect= 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ôme | Cause racine | Correctif senior |
|---|---|---|
Un computed ne se met jamais à jour | Tu as lu une valeur avant le tracking (ex. const v = sig(); return () => v) ou hors contexte réactif | Lis 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 changement | track 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 grimpe | effect/subscription non nettoyé hors contexte d'injection | effect se nettoie seul dans un contexte DI ; sinon DestroyRef |
Ordre de lecture recommandé
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- 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. - N2 (change detection) est le pivot senior. C'est là que se gagne ou se perd la perf. Lis-le deux fois.
- Forms / Routing (N3) sont autonomes : tu peux y aller dès que N1 est solide.
- State (N4) et Perf (N5) supposent N2 acquis (OnPush, zoneless, interop).
- 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)
| Feature | Introduit | Stable / recommandé |
|---|---|---|
| Standalone components | v14 (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/@defer | v17 | v17+ stable |
input() / output() signal-based | v17.1 / v17.3 | v18+ |
model() (two-way signal) | v17.2 | v18+ |
| Zoneless change detection | v18 (experimental) | v20 (developer preview avancé) — production-viable avec tests |
| Hydration non-destructive + event replay | v16 / v18 | v18+ |
@let dans les templates | v18.1 | v18.1+ |
linkedSignal / resource() / httpResource | v19 | v19+ (preview → stabilisation v20) |
⚠️ Si tu vois encore
NgModule,*ngFor,BrowserModuleounew 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.
- 01 — Standalone components & inject()
- 02 — Signals (signal, computed, effect)
- 03 — Control flow (@if, @for, @switch, @defer)
- 04 — Dependency Injection (injecteurs hiérarchiques)
- 05 — Migration Modules → Standalone
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).
- 01 — RxJS — opérateurs essentiels
- 02 — Signals vs RxJS (interop : toSignal / toObservable)
- 03 — Change detection (OnPush / signals / zoneless)
- 04 — Effects vs lifecycle hooks
Arbre de décision Signal vs RxJS :
| Besoin | Outil | Raison |
|---|---|---|
| État synchrone (compteur, filtre, sélection) | signal / computed | Pas 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 switchMap | Cancellation propre des requêtes en vol |
| Combiner état + flux | toSignal(obs$) / toObservable(sig) | Interop bidirectionnelle |
Niveau 3 — Forms & Routing
Objectif : formulaires typés (
FormGroup<T>), validation async sans race, routing lazy avecwithComponentInputBinding.
- 01 — Reactive forms deep dive
- 02 — Template-driven forms
- 03 — Routing (lazy, guards, resolvers, input binding)
- 04 — Validation custom + async
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.
- 01 — NgRx (store, effects, selectors, entity)
- 02 — NgRx Signal Store
- 03 — State par services + signals
Échelle de décision (du plus simple au plus lourd) :
| Échelle | Solution | Quand |
|---|---|---|
| Composant local | signal dans le composant | État UI éphémère |
| Feature partagée | Service @Injectable + signals (providedIn ou route-scoped) | Le défaut moderne pour 80 % des cas |
| Feature avec logique riche | NgRx Signal Store (signalStore, withState, withMethods, withComputed) | Store réactif sans boilerplate Redux |
| App très large, audit/temps-voyage, équipes multiples | NgRx 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.
- 01 — Testing (Jest, Testing Library, Cypress, Playwright)
- 02 — Change detection performance
- 03 — Bundle size, lazy, defer
- 04 — Accessibilité (a11y)
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.
- 01 — SSR / hydration / non-destructive
- 02 — PWA / service workers
- 03 — Déploiement & DevTools
- 04 — i18n
- 05 — Versions 16 → 20 (signals timeline)
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).
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 :
| Transport | Streaming token | Annulation | Reconnexion auto | Quand |
|---|---|---|---|---|
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 | ✅ bidirectionnel | ✅ close() | ❌ (à 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 :
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;
}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.
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
markedetbypassSecurityTrustHtml—bypassseul 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 :
<!-- 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/ReadableStream → getReader() + 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.