Signals — la réactivité fine d'Angular
TL;DR — Les signals sont la nouvelle primitive de réactivité d'Angular, introduite en preview en v16, stabilisée en v17, polie en v18, et au cœur du mode zoneless stable en v20. Un signal est une valeur observable typée dont on peut lire le contenu (
s()), qu'on peut muter (s.set(v)ous.update(fn)), et auquel d'autres calculs (computed) ou effets (effect) peuvent réagir automatiquement. Le runtime Angular suit les dépendances à la lecture : il n'y a plus besoin de déclarer manuellement ce qu'on observe. Comparés à RxJS, les signals sont synchrones, glitch-free, et bien plus simples pour l'état d'UI. RxJS reste roi pour les flux asynchrones et opérateurs (debounce, switchMap…), et l'interopérabilité passe partoSignal/toObservable.
🧠 Mental model — ASCII + analogie
L'analogie la plus parlante est celle d'un tableur Excel : chaque cellule détient une valeur (un signal), certaines cellules contiennent une formule qui dépend d'autres cellules (un computed), et l'écran se rafraîchit automatiquement quand une valeur change (un effect, ou la détection de changements zoneless).
┌─────────────┐ ┌───────────────────────────────┐
│ signal │ read │ computed │
│ count = 3 │ ─────▶ │ doubled = count() * 2 = 6 │
└─────▲───────┘ └───────────────▲───────────────┘
│ set/update │ read (auto)
│ │
┌─────┴───────┐ ┌───────┴────────┐
│ user click │ │ template │
│ → count++ │ │ {{ doubled() }} │
└─────────────┘ └────────────────┘
┌─────────────────────────────┐
│ effect(() => { │
│ console.log(count(), │
│ doubled()); │
│ }) ← s'exécute │
│ automatiquement │
└─────────────────────────────┘Le runtime maintient en interne un graphe de dépendances. Quand on lit count() à l'intérieur d'un computed ou d'un effect, Angular enregistre la dépendance. Quand count change, seuls les nœuds dépendants sont marqués sales et re-évalués au besoin (pull, lazy) — ce qui supprime la grande complexité de la zone de change detection traditionnelle.
Un autre point clé : le graphe est glitch-free. Si a change et que b = computed(a()) puis c = computed(a() + b()), c ne verra jamais la combinaison incohérente (ancien a, nouveau b). Ce n'est pas le cas naïf avec RxJS sans combineLatest/zip bien orchestré.
🛠️ Code minimal
import { Component, signal, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<button (click)="decrement()">-</button>
<span>{{ count() }} (doublé : {{ doubled() }})</span>
<button (click)="increment()">+</button>
@if (isHigh()) {
<p>Attention, c'est élevé !</p>
}
`,
})
export class CounterComponent {
protected readonly count = signal(0);
protected readonly doubled = computed(() => this.count() * 2);
protected readonly isHigh = computed(() => this.count() >= 10);
constructor() {
// Effet : logue à chaque changement de count
effect(() => console.log('count =', this.count()));
}
increment(): void {
this.count.update((n) => n + 1);
}
decrement(): void {
this.count.update((n) => n - 1);
}
reset(): void {
this.count.set(0);
}
}À noter dans le template : on appelle le signal (count()) comme une fonction, parce qu'un signal est implémenté en interne comme une closure. Cette signature explicite est délibérée : c'est ce qui permet à Angular de tracer précisément les lectures.
🎯 Patterns courants
0. Signal store maison — pattern minimal
Avant d'introduire NgRx ou autre, on peut déjà construire un mini-store très propre :
import { Injectable, signal, computed } from '@angular/core';
interface CartState {
items: Array<{ id: string; qty: number; price: number }>;
coupon: string | null;
}
@Injectable({ providedIn: 'root' })
export class CartStore {
// état privé en signal
private readonly state = signal<CartState>({ items: [], coupon: null });
// sélecteurs publics (signals readonly)
readonly items = computed(() => this.state().items);
readonly count = computed(() => this.items().reduce((n, i) => n + i.qty, 0));
readonly total = computed(() => this.items().reduce((s, i) => s + i.qty * i.price, 0));
readonly hasCoupon = computed(() => this.state().coupon !== null);
// mutations explicites
add(item: CartState['items'][number]): void {
this.state.update((s) => ({ ...s, items: [...s.items, item] }));
}
remove(id: string): void {
this.state.update((s) => ({ ...s, items: s.items.filter((i) => i.id !== id) }));
}
applyCoupon(code: string): void {
this.state.update((s) => ({ ...s, coupon: code }));
}
reset(): void {
this.state.set({ items: [], coupon: null });
}
}Avantages : zéro dépendance externe, type sûr, glitch-free, testable trivialement. Pour des stores plus gros, on peut basculer sur NgRx SignalStore qui formalise ce pattern.
1. Dérivation locale d'état (formulaire)
@Component({ /* ... */ })
export class CheckoutComponent {
protected readonly quantity = signal(1);
protected readonly unitPrice = signal(9.99);
protected readonly discountPct = signal(0);
protected readonly subtotal = computed(() => this.quantity() * this.unitPrice());
protected readonly discount = computed(() => this.subtotal() * (this.discountPct() / 100));
protected readonly total = computed(() => this.subtotal() - this.discount());
}Toute la dérivée descend du même graphe : changer quantity propage automatiquement à subtotal, discount, total. Pas de BehaviorSubject à orchestrer, pas d'async pipe, pas de risque de stream non disposé.
2. Égalité personnalisée
const user = signal(
{ id: 1, name: 'Anita' },
{ equal: (a, b) => a.id === b.id && a.name === b.name }
);Par défaut, Angular utilise Object.is. Pour des objets sémantiquement comparables (deep equality, comparaison par id), on passe une fonction equal. Cela évite les notifications inutiles aux dépendants.
3. untracked pour lire sans s'abonner
effect(() => {
// ne dépend QUE de userId, pas de version
const id = this.userId();
const v = untracked(() => this.version()); // lit la valeur sans créer de dépendance
console.log('user', id, 'cached v', v);
});Indispensable pour éviter les boucles infinies dans effect, ou quand on veut consommer un signal "pour info" sans déclencher un nouveau passage.
4. Interop RxJS : toSignal / toObservable
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, distinctUntilChanged, switchMap } from 'rxjs';
@Component({ /* ... */ })
export class SearchComponent {
private readonly api = inject(SearchApi);
protected readonly query = signal('');
// signal -> observable : tirer parti des opérateurs RxJS
private readonly query$ = toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap((q) => this.api.search(q))
);
// observable -> signal : exposer le résultat à la vue, avec valeur initiale
protected readonly results = toSignal(this.query$, { initialValue: [] });
}C'est le pattern de référence en 2026 : RxJS reste pour la pipeline asynchrone (debounce, switchMap, retry, cancel), et le résultat final atterrit dans un signal. Le composant reste 100% lisible côté template (results()), et zoneless-compatible.
5. linkedSignal (v19+) — état dérivé éditable
import { linkedSignal } from '@angular/core';
// Préselectionne la première option, mais l'utilisateur peut changer.
// Si la liste change, on revient sur la nouvelle première option.
protected readonly options = signal<Option[]>([]);
protected readonly selected = linkedSignal<Option[], Option | null>({
source: this.options,
computation: (opts, prev) => {
// Garde la sélection précédente si elle existe toujours
if (prev?.value && opts.includes(prev.value)) return prev.value;
return opts[0] ?? null;
},
});linkedSignal est le bon outil quand on a besoin à la fois d'une dérivation depuis un autre signal et de pouvoir écrire manuellement dessus (typiquement un selected qui suit une liste mais autorise un override utilisateur).
6. resource et rxResource (v19+)
import { resource } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
@Component({ /* ... */ })
export class UserCardComponent {
protected readonly userId = signal('42');
// Version promise-based
// NB v20+ : le champ s'appelle `params` (anciennement `request` en v19).
protected readonly user = resource({
params: () => ({ id: this.userId() }),
loader: async ({ params, abortSignal }) => {
const res = await fetch(`/api/users/${params.id}`, { signal: abortSignal });
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json() as Promise<User>;
},
});
// -> user.value(), user.status(), user.error(), user.isLoading(), user.reload()
// Version RxJS
protected readonly userRx = rxResource({
params: () => this.userId(),
stream: ({ params }) => this.http.get<User>(`/api/users/${params}`),
});
}Le ressource gère automatiquement le cancel des requêtes obsolètes via abortSignal — fini les courses de requêtes. C'est le remplaçant officiel des patterns à base de switchMap quand on n'a pas besoin de toute la pipeline RxJS.
7. Inputs et model en signal (v17.1+ / v18)
import { Component, input, model, output, computed } from '@angular/core';
@Component({
selector: 'app-pricing',
standalone: true,
template: `
<input type="number" [value]="qty()" (input)="qty.set(+$any($event.target).value)" />
Total : {{ total() }}
<button (click)="add.emit()">+</button>
`,
})
export class PricingComponent {
// input() = équivalent moderne de @Input
readonly unitPrice = input.required<number>();
readonly tax = input<number>(0.2); // valeur par défaut
// model() = two-way binding signal-native (équivalent moderne de @Input + @Output)
readonly qty = model<number>(1);
// output() = équivalent moderne de @Output (EventEmitter sous le capot, API plus stricte)
readonly add = output<void>();
readonly total = computed(() => this.qty() * this.unitPrice() * (1 + this.tax()));
}Côté parent :
<!-- two-way binding sur un model -->
<app-pricing [unitPrice]="49.99" [(qty)]="cartQty" (add)="onAdd()" />Les input() / model() / output() sont les nouvelles API canoniques d'Angular 17.1/18. Avantages : typage strict (input.required<T>() rend l'oubli détectable au compile-time), pas besoin de ngOnChanges, intégration native dans le graphe réactif (un computed qui lit unitPrice() se re-évalue automatiquement quand le parent change la valeur).
8. Queries en signal (viewChild, contentChild, viewChildren)
import { Component, viewChild, viewChildren, ElementRef, afterNextRender } from '@angular/core';
@Component({ /* ... */ })
export class FormComponent {
// signal-based view query — undefined tant que la vue n'est pas rendue
readonly emailInput = viewChild<ElementRef<HTMLInputElement>>('emailInput');
readonly items = viewChildren<ListItemComponent>(ListItemComponent);
constructor() {
afterNextRender(() => {
this.emailInput()?.nativeElement.focus();
});
}
}viewChild() retourne un signal qui se met à jour quand la vue change. Plus besoin de @ViewChild + ngAfterViewInit. Le timing est garanti par afterNextRender / afterRender (callbacks post-rendu, hors zone).
9. Effet pour les side-effects (DOM, log, sync localStorage)
effect(() => {
const t = this.theme();
document.documentElement.setAttribute('data-theme', t);
localStorage.setItem('theme', t);
});Règle d'or : un effect est pour les effets de bord. S'il calcule une nouvelle valeur dérivée, c'est computed qu'il faut.
⚠️ Note de version sur
allowSignalWrites— en v16/17/18, écrire un signal dans uneffectexigeaiteffect(fn, { allowSignalWrites: true }), sinon le runtime levait. Depuis v19, le flagallowSignalWritesa été supprimé : l'écriture est autorisée par défaut. Le danger n'a pas disparu pour autant — écrire dans un signal lu par le même effect (ou par uncomputeddont l'effect dépend) ré-ouvre un cycle. La bonne pratique reste : zéro écriture de signal dans un effect dans 90 % des cas ; pour de la dérivation éditable, utiliserlinkedSignal; pour synchroniser deux signaux, repenser le graphe plutôt que de l'écrire dans un effect.
🔄 Versions — Angular 16 / 17 / 18 / 19 / 20
| Version | Apport principal sur les signals |
|---|---|
| 16 (mai 2023) | Preview : signal(), computed(), effect(), toSignal/toObservable. API marquée preview, susceptible d'évoluer. mutate() existait pour modifier en place. |
| 17 (nov 2023) | API stable (sauf effect qui reste preview, et mutate() est retiré au profit de update()). Le nouveau control flow @if/@for est aussi 100% signal-aware : Angular trace la lecture du signal dans le template et invalide finement. |
| 18 (mai 2024) | effect() stable. provideExperimentalZonelessChangeDetection() introduit (zoneless en developer preview). Inputs en signals : input() et model() deviennent les nouvelles APIs typées. viewChild/contentChild en signal queries. |
| 19 (nov 2024) | linkedSignal(), resource(), rxResource() arrivent (preview/stable selon les APIs). Schematics pour migrer @Input → input(). Zoneless toujours en developer preview mais beaucoup plus utilisable. |
| 20 (mai 2025) | Zoneless stable via provideZonelessChangeDetection(). L'app peut tourner sans Zone.js (bundle plus petit, perfs prévisibles). Toutes les APIs signal sont stables ; le mode zone reste supporté pour compat. |
Le timeline pratique :
2023 ──┬── v16 preview signals
│
2023 ──┴── v17 stable + control flow signal-aware
│
2024 ──┬── v18 effect stable, input()/model(), zoneless preview
│
2024 ──┴── v19 linkedSignal, resource, schematics input migration
│
2025 ──── v20 zoneless STABLEÀ retenir : si tu apprends Angular en 2026, les signals sont la primitive par défaut, pas une option. Les nouveaux composants n'utilisent quasiment plus de BehaviorSubject pour l'état d'UI.
⚠️ Pitfalls
- Lire un signal hors composant/effet — La lecture marche, mais sans s'abonner. Si on s'attendait à un suivi, c'est un bug silencieux. La règle :
s()est traçant uniquement dans un contexte réactif (computed,effect, template). - Boucle d'
effect— Écrire un signal qu'on lit dans le mêmeeffectprovoque une boucle (et une erreur en mode dev). Solution :untracked()autour de la lecture, ou repenser viacomputed. effectpour calculer — Mauvais :effect(() => total.set(price() * qty())). Le bon outil estcomputed. Sinon on perd la garantie glitch-free et on rouvre le risque de cycle.setvsupdatevs ancienmutate—set(v)remplace,update(fn)calcule depuis la valeur courante (atomique).mutateexistait en v16 pour muter un objet en place ; il a été retiré en v17 parce qu'il rendait l'égalité ambiguë. Pour les objets, faireupdate(o => ({ ...o, x: v })).- Égalité par référence par défaut — Pour les objets, deux objets
{ a: 1 }différents seront vus comme différents même avec mêmes clés. Si on construit l'objet à chaque tick, on déclenche partout. Passerequalou utiliser des structures plus stables. - Mélanger zone + zoneless — En zoneless, certaines libs tierces qui supposent Zone.js (vieux libs Material, certaines libs de date) peuvent ne plus déclencher de CD. Tester avant migration.
toSignalsansinitialValue— Le signal sera typéT | undefinedjusqu'à la première émission. Penser à{ initialValue: x }ou à{ requireSync: true }(lève si pas d'émission synchrone).- Capture d'
inject()danseffect—effectcrée son contexte d'injection ; appelerinject()à l'intérieur fonctionne mais n'est pas équivalent à l'extérieur. Préférer capter les services en champs de classe. computedqui se re-run trop souvent — Si on lit beaucoup de signals dont peu sont vraiment utiles à la sortie, on s'abonne à tous. Bien découper en plusieurscomputedplus petits.- DestroyRef et signals —
effectest automatiquement nettoyé quand le composant est détruit (s'il est créé dans le contexte d'injection du composant). Mais si on crée uneffectdans unsetTimeout, il ne sera pas lié : fuite de mémoire. Toujours créer les effects dans le constructeur, dans un champ, ou explicitement avecrunInInjectionContext. - Confondre
effectetcomputed—effectn'a pas de valeur de retour utilisable. C'est purement pour les side-effects. Tout ce qui produit une valeur dérivée doit être uncomputed. signal()avec un type vide —signal()(sans valeur) est interdit ; il faut une valeur initiale ousignal<T | null>(null). C'est volontaire pour éviter lesundefinedcachés.computedqui dépend d'un signal qu'il met à jour viaeffect— Cycle classique. Le compilateur ne le détecte pas, mais le runtime lèveNG0600ou loope silencieusement. Refactor enlinkedSignal.- Lecture conditionnelle dans
computed— Si tu lisa()puisb()dans une branche if, et que la première lecture metfalsela condition,bne sera pas une dépendance. C'est correct (dépendances dynamiques), mais piégeur si on s'attendait à un re-run quandbchange.
🧪 Testing — TestBed + signal testing
import { TestBed } from '@angular/core/testing';
import { CounterComponent } from './counter.component';
describe('CounterComponent', () => {
it('met à jour le doublé quand on incrémente', () => {
const fixture = TestBed.createComponent(CounterComponent);
const cmp = fixture.componentInstance;
fixture.detectChanges();
expect(cmp['doubled']()).toBe(0);
cmp['count'].set(5);
fixture.detectChanges(); // déclenche le rendu, pas le calcul du computed
expect(cmp['doubled']()).toBe(10);
});
});Tester un effect
import { TestBed } from '@angular/core/testing';
import { effect, signal } from '@angular/core';
it('log effect', () => {
const c = signal(0);
const logs: number[] = [];
TestBed.runInInjectionContext(() => {
effect(() => logs.push(c()));
});
TestBed.tick(); // v18+ : exécute le scheduler (effects en attente). v16/17 : TestBed.flushEffects()
c.set(1);
TestBed.tick();
expect(logs).toEqual([0, 1]);
});TestBed.tick() (Angular 18+, qui remplace TestBed.flushEffects()) est crucial : un effect n'est pas synchrone à la création. Il est planifié par le scheduler d'Angular (microtask en mode zone, ou via le ChangeDetectionScheduler en zoneless) et ne s'exécute qu'au prochain tick. Sans tick(), l'assertion court avant que l'effect n'ait tourné. En zoneless réel (provideZonelessChangeDetection()), await fixture.whenStable() est l'équivalent côté composant.
Tester toSignal + RxJS
import { Subject } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
it('expose les valeurs RxJS dans un signal', () => {
const src = new Subject<number>();
TestBed.runInInjectionContext(() => {
const s = toSignal(src, { initialValue: -1 });
expect(s()).toBe(-1);
src.next(42);
expect(s()).toBe(42);
});
});Tester un composant avec inputs signal
import { TestBed } from '@angular/core/testing';
import { PricingComponent } from './pricing.component';
it('calcule le total à partir des inputs', () => {
const fixture = TestBed.createComponent(PricingComponent);
fixture.componentRef.setInput('unitPrice', 100);
fixture.componentRef.setInput('tax', 0.2);
fixture.componentInstance.qty.set(3);
fixture.detectChanges();
expect(fixture.componentInstance['total']()).toBeCloseTo(360);
});componentRef.setInput('name', value) est l'API officielle pour piloter les input() depuis un test, équivalent à un parent qui passerait la valeur dans son template.
Mocker un resource
const fakeResource = {
value: signal<User | undefined>({ id: '1', name: 'Mock' }),
status: signal<'idle' | 'loading' | 'success' | 'error'>('success'),
isLoading: signal(false),
error: signal(undefined),
reload: () => {},
};
TestBed.overrideProvider(USER_RESOURCE, { useValue: fakeResource });C'est le pattern : encapsuler les resource derrière un token et les mocker par injection.
🎬 Cas d'usage concrets
Scénario 1 — Dashboard de banque privée, refonte signal-driven
Une banque privée maintient un poste de travail conseiller qui affiche en temps réel le portefeuille d'un client (positions, cash, alertes risque, market data). L'ancienne version reposait sur un BehaviorSubject central + combineLatest pour dériver les KPIs. À chaque tick de marché (~3-4 par seconde), tout l'écran rerendrait. Les conseillers se plaignaient de saccades pendant l'ouverture de Paris.
L'équipe migre les états locaux du dashboard vers des signaux. Les positions deviennent un signal<Position[]>, le total portefeuille un computed(() => positions().reduce(...)), et chaque KPI dérive d'un computed qui ne se recalcule que si ses dépendances changent réellement (égalité structurelle via equal). Résultat : la latence d'interaction tombe sous 16 ms même quand 200 lignes de positions s'actualisent. Le code, lui, devient lisible : plus d'async pipe partout, plus de combineLatest à 5 sources.
Le flux temps réel (WebSocket) reste en RxJS — c'est sa zone de force — mais à l'arrivée d'un message, on fait un simple positions.update(prev => merge(prev, msg)). La frontière RxJS → signal est claire : toSignal() pour l'entrée du flux, toObservable() quand on doit redonner du signal à une lib qui n'attend que des Observable.
Scénario 2 — E-commerce, panier signals
Une boutique en ligne de cosmétiques voit son panier comme un mini-state-management. Avant : un CartService avec BehaviorSubject<CartLine[]>, un total$ calculé par map, et un count$ par un autre map. Chaque composant qui voulait afficher le total devait s'abonner via async pipe, ce qui marchait, mais devenait verbeux dans le header, le drawer, la page checkout et les recommandations.
Refonte : le CartService expose lines = signal<CartLine[]>([]), total = computed(() => this.lines().reduce(...)), count = computed(() => this.lines().length). Dans le template, on lit le signal cart.total() directement en interpolation. Pas de désabonnement, pas d'async pipe, pas de risque d'oublier une projection. Le temps de rendu de la page checkout, qui faisait 14 souscriptions, tombe à 0 (lecture synchrone).
Bonus : la persistance localStorage devient triviale avec un effect(() => localStorage.setItem('cart', JSON.stringify(cart.lines()))). Cet effect tracke automatiquement lines et persiste à chaque mutation. Avant, il fallait un tap() dans le pipeline RxJS.
Scénario 3 — Immobilier, recherche live avec signals
Un portail immobilier propose une recherche avec ~12 filtres (prix, surface, pièces, ville, type, dates). L'utilisateur ajuste un slider de prix : il faut requêter l'API, mettre à jour la carte, mettre à jour la liste, mettre à jour les compteurs. L'ancienne implémentation utilisait un FormGroup + valueChanges + debounceTime(300) + switchMap vers l'API.
L'équipe garde le formulaire réactif (pratique pour la validation et la sérialisation URL), mais expose la valeur du formulaire en signal : filters = toSignal(this.form.valueChanges.pipe(debounceTime(300)), { initialValue: this.form.value }). Les dérivés visuels (nombre de résultats, libellés synthétiques des filtres actifs, état "trop de résultats") sont des computed sur filters() et results(). Le composant n'a plus aucun subscribe manuel.
Décision : ne pas mettre l'appel HTTP dans un computed. Un computed doit rester pur et synchrone. La recherche se fait dans un effect qui surveille filters() et déclenche le switchMap côté RxJS, puis pousse le résultat dans un signal results.
🛠️ Exemple end-to-end
Use case : panier d'un site e-commerce. Mini-state-management 100 % signaux, avec sélecteurs dérivés, effet de persistance et intégration RxJS pour l'API checkout.
// cart.types.ts
export interface CartLine {
productId: string;
name: string;
unitPriceCents: number;
qty: number;
}// cart.store.ts
import { Injectable, computed, effect, signal } from '@angular/core';
const STORAGE_KEY = 'cart.v1';
@Injectable({ providedIn: 'root' })
export class CartStore {
private readonly _lines = signal<CartLine[]>(this.restore());
readonly lines = this._lines.asReadonly();
readonly count = computed(() => this._lines().reduce((a, l) => a + l.qty, 0));
readonly subtotalCents = computed(() =>
this._lines().reduce((a, l) => a + l.qty * l.unitPriceCents, 0),
);
readonly isEmpty = computed(() => this._lines().length === 0);
constructor() {
effect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._lines()));
});
}
add(line: Omit<CartLine, 'qty'>, qty = 1): void {
this._lines.update((lines) => {
const existing = lines.find((l) => l.productId === line.productId);
if (existing) {
return lines.map((l) =>
l.productId === line.productId ? { ...l, qty: l.qty + qty } : l,
);
}
return [...lines, { ...line, qty }];
});
}
setQty(productId: string, qty: number): void {
if (qty <= 0) return this.remove(productId);
this._lines.update((lines) =>
lines.map((l) => (l.productId === productId ? { ...l, qty } : l)),
);
}
remove(productId: string): void {
this._lines.update((lines) => lines.filter((l) => l.productId !== productId));
}
clear(): void {
this._lines.set([]);
}
private restore(): CartLine[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as CartLine[]) : [];
} catch {
return [];
}
}
}// cart-summary.component.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { CartStore } from './cart.store';
@Component({
selector: 'app-cart-summary',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CurrencyPipe],
template: `
@if (cart.isEmpty()) {
<p>Votre panier est vide.</p>
} @else {
<ul>
@for (line of cart.lines(); track line.productId) {
<li>
{{ line.name }} × {{ line.qty }}
— {{ line.unitPriceCents * line.qty / 100 | currency: 'EUR' }}
<button (click)="cart.setQty(line.productId, line.qty - 1)">-</button>
<button (click)="cart.setQty(line.productId, line.qty + 1)">+</button>
</li>
}
</ul>
<p><strong>Total : {{ cart.subtotalCents() / 100 | currency: 'EUR' }}</strong></p>
<p>{{ cart.count() }} article(s)</p>
}
`,
})
export class CartSummaryComponent {
protected readonly cart = inject(CartStore);
}// checkout.service.ts (frontière signal → RxJS)
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { CartStore } from './cart.store';
@Injectable({ providedIn: 'root' })
export class CheckoutService {
private readonly http = inject(HttpClient);
private readonly cart = inject(CartStore);
startCheckout() {
// Lecture synchrone des signaux — aucune souscription nécessaire
const payload = { lines: this.cart.lines(), totalCents: this.cart.subtotalCents() };
return this.http.post<{ checkoutUrl: string }>('/api/checkout', payload);
}
}Le store est entièrement en signaux. Le computed recalcule paresseusement, l'effect persiste à chaque mutation, et la transition vers RxJS se fait juste pour l'appel HTTP. Aucun subscribe, aucun async pipe, aucun risque de fuite mémoire.
🤖 Signals pour une UI d'agent IA en streaming
C'est le cas d'usage moderne où les signals brillent et où ce learner va vivre : un front Angular qui consomme un agent IA (un endpoint NestJS qui relaie Claude — claude-opus-4-8, claude-sonnet-4-6, claude-haiku-4-5) en streaming SSE/ReadableStream, affiche les tokens au fil de l'eau, trace les appels d'outils (tool_use), et propose un bouton Stop qui annule client ET serveur. Le signal est la primitive idéale : buffer append-only, rendu coalescé par rAF, et compatible zoneless natif (pas de NgZone.run à câbler).
Mental model du streaming réactif
ReadableStream (fetch) signal<Message[]> template (zoneless)
────────────────────── ───────────────── ───────────────────
getReader() messages.update( @for (m of messages())
TextDecoder append token au rAF-coalescé →
parse SSE lines ──token──▶ dernier message ──auto──▶ markdown rendu
) (DomSanitizer)
│ ▲
│ tool_use event │
└──────────────────────────────┘ toolTrace.update(... 'running' → 'done')Le runtime ne re-rend que les nœuds qui dépendent réellement du signal muté. À 60 tokens/s, ça veut dire des centaines de set par seconde — on coalesce sous un requestAnimationFrame pour ne peindre qu'une fois par frame.
1. Service de streaming avec fetch + getReader() + AbortController
// agent-stream.service.ts
import { Injectable, signal, computed, NgZone, inject } from '@angular/core';
// Discriminated union — le timeline d'un appel d'outil
export type ToolCall =
| { id: string; name: string; status: 'pending'; input: unknown }
| { id: string; name: string; status: 'running'; input: unknown }
| { id: string; name: string; status: 'done'; input: unknown; output: unknown }
| { id: string; name: string; status: 'error'; input: unknown; error: string };
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
text: string; // accumulé token par token
tools: ToolCall[];
streaming: boolean;
}
@Injectable({ providedIn: 'root' })
export class AgentStreamService {
private readonly zone = inject(NgZone);
private readonly _messages = signal<ChatMessage[]>([]);
readonly messages = this._messages.asReadonly();
// Statut dérivé : la vue désactive l'input et montre "Stop" pendant le run
readonly isStreaming = computed(() => this._messages().some((m) => m.streaming));
private controller: AbortController | null = null;
private pendingText = ''; // buffer hors signal, vidé au rAF
private rafId: number | null = null;
async send(prompt: string): Promise<void> {
// 1. message user + coquille assistant (append-only)
const userId = crypto.randomUUID();
const asstId = crypto.randomUUID();
this._messages.update((ms) => [
...ms,
{ id: userId, role: 'user', text: prompt, tools: [], streaming: false },
{ id: asstId, role: 'assistant', text: '', tools: [], streaming: true },
]);
this.controller = new AbortController();
try {
const res = await fetch('/api/agent/stream', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ prompt }),
signal: this.controller.signal, // ← annulation client
});
if (!res.ok || !res.body) throw new Error('HTTP ' + res.status);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Découpe les évènements SSE (séparés par \n\n)
const events = buffer.split('\n\n');
buffer = events.pop() ?? ''; // garde le fragment incomplet
for (const evt of events) {
this.handleSseEvent(asstId, evt);
}
}
} catch (e) {
if ((e as Error).name !== 'AbortError') {
this.appendText(asstId, `\n\n_[erreur: ${(e as Error).message}]_`);
}
} finally {
this.flushNow(asstId);
this._messages.update((ms) =>
ms.map((m) => (m.id === asstId ? { ...m, streaming: false } : m)),
);
this.controller = null;
}
}
stop(): void {
// Annule la requête côté client. Le serveur reçoit l'abort sur sa propre
// requête (AbortController relayé jusqu'au SDK Anthropic) → cancel réel,
// pas juste un masquage UI.
this.controller?.abort();
}
// --- internes ---
private handleSseEvent(asstId: string, raw: string): void {
// raw = "event: ...\ndata: {...}" — on ne lit que la ligne data ici
const line = raw.split('\n').find((l) => l.startsWith('data:'));
if (!line) return;
const payload = line.slice(5).trim();
if (payload === '[DONE]') return;
const evt = JSON.parse(payload) as
| { type: 'token'; text: string }
| { type: 'tool_use'; id: string; name: string; input: unknown }
| { type: 'tool_result'; id: string; output?: unknown; error?: string };
switch (evt.type) {
case 'token':
this.appendText(asstId, evt.text); // ← coalescé rAF
break;
case 'tool_use':
this._messages.update((ms) =>
ms.map((m) =>
m.id === asstId
? { ...m, tools: [...m.tools, { id: evt.id, name: evt.name, status: 'running', input: evt.input }] }
: m,
),
);
break;
case 'tool_result':
this._messages.update((ms) =>
ms.map((m) =>
m.id === asstId
? {
...m,
tools: m.tools.map((t) =>
t.id === evt.id
? evt.error
? { ...t, status: 'error', error: evt.error }
: { ...t, status: 'done', output: evt.output }
: t,
),
}
: m,
),
);
break;
}
}
/** Bufferise le token et planifie un seul flush par frame. */
private appendText(asstId: string, chunk: string): void {
this.pendingText += chunk;
if (this.rafId != null) return;
// En zoneless, requestAnimationFrame ne déclenche pas de CD : on reste
// dans le scheduler signal, donc le set() suffit à repeindre.
this.rafId = requestAnimationFrame(() => {
this.rafId = null;
this.flushNow(asstId);
});
}
private flushNow(asstId: string): void {
if (!this.pendingText) return;
const chunk = this.pendingText;
this.pendingText = '';
this._messages.update((ms) =>
ms.map((m) => (m.id === asstId ? { ...m, text: m.text + chunk } : m)),
);
}
}Pourquoi update et pas mutate : on construit un nouveau tableau / nouveau message à chaque token. Coûteux ? Non — le computed/template ne re-lit que ce qui change, et l'immutabilité garde le graphe glitch-free. mutate n'existe plus (retiré en v17). Si le volume devient un goulot (très longues conversations), on shard : un signal<string> par message en cours de stream, et un signal<ChatMessage[]> pour l'historique figé — seul le shard du message actif est muté à chaque token.
2. Composant : rendu markdown + bouton Stop + timeline d'outils
// chat.component.ts
import { ChangeDetectionStrategy, Component, inject, computed } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { AgentStreamService } from './agent-stream.service';
declare const marked: { parse(md: string): string }; // ex. import { marked } from 'marked'
@Component({
selector: 'app-chat',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (m of agent.messages(); track m.id) {
<article [class.assistant]="m.role === 'assistant'">
<div [innerHTML]="render(m.text)"></div>
@for (t of m.tools; track t.id) {
<div class="tool" [attr.data-status]="t.status">
<code>{{ t.name }}</code>
@switch (t.status) {
@case ('running') { <span class="spinner">…</span> }
@case ('done') { <span>✓</span> }
@case ('error') { <span class="err">✕ {{ t.error }}</span> }
}
</div>
}
@if (m.streaming) { <span class="caret">▋</span> }
</article>
}
<button (click)="agent.stop()" [disabled]="!agent.isStreaming()">Stop</button>
`,
})
export class ChatComponent {
protected readonly agent = inject(AgentStreamService);
private readonly sanitizer = inject(DomSanitizer);
protected render(md: string): SafeHtml {
// marked produit du HTML ; bypassSecurityTrust seulement si on contrôle
// la source (ici : sortie d'un LLM qu'on assainit en amont côté serveur,
// ou via DOMPurify). Ne JAMAIS bypass sur du markdown utilisateur brut.
return this.sanitizer.bypassSecurityTrustHtml(marked.parse(md));
}
}🔒 Sécurité du rendu markdown :
marked.parsene nettoie pas le HTML. Un LLM peut émettre<img onerror=…>ou un<script>(prompt injection visant l'UI). Pipeline correct :marked.parse(md)→ DOMPurify →bypassSecurityTrustHtml. Sans DOMPurify, on s'expose à du XSS stocké.bypassSecurityTrust*est un fusil chargé : ne l'utiliser qu'après assainissement, jamais sur la sortie brute du modèle.
Comment un staff engineer raisonne sur cette UI
| Décision | Choix | Pourquoi |
|---|---|---|
| Buffer de tokens | signal<ChatMessage[]> append-only | Glitch-free, zoneless-natif, pas de Subject à disposer |
| Cadence de peinture | coalescence rAF | 1 repaint/frame au lieu de N sets/s → pas de jank à 60 tok/s |
| Annulation | AbortController client + relais serveur | Stop visuel = stop réel (le SDK Anthropic reçoit l'abort, on arrête de payer des tokens) |
| Trace d'outils | discriminated union pending|running|done|error | Le compilateur force à gérer chaque état ; @switch exhaustif |
| Markdown | marked + DOMPurify + DomSanitizer | XSS = la faille n°1 d'une UI LLM |
| Statut UI | computed(isStreaming) | Dérivé pur, pas de flag à synchroniser à la main |
| Très longues sessions | shard signal par message actif | Limite la taille de l'objet muté à chaque token |
Le point de vigilance senior : le bouton Stop ne doit jamais être cosmétique. Annuler côté client sans relayer l'AbortSignal jusqu'à l'appel Claude côté NestJS = le serveur continue à générer (et à facturer) dans le vide. Le contrat de bout en bout : controller.abort() → fetch rompu → NestJS détecte la déconnexion (req.on('close') / AbortController passé au SDK) → le stream Anthropic est coupé. C'est exactement le même AbortController qu'on passe au SDK Anthropic côté serveur (client.messages.stream({ signal })).
🔁 Quand utiliser / éviter
| Utiliser quand | Éviter quand |
|---|---|
| État local d'UI (formulaires, toggles, sélection) | Streams asynchrones complexes (préférer RxJS, puis toSignal à la fin) |
État dérivé (computed) — total, validation, visibilité | Effet de bord complexe (HTTP, navigation) — préférer une fonction explicite |
| Préparation au zoneless | Lib tierce qui n'expose qu'un Observable (la convertir avec toSignal est OK) |
Synchronisation simple localStorage / DOM via effect | Boucles d'écriture/lecture sur le même signal (rouvre les cycles) |
| Migration douce depuis BehaviorSubject (1 BS = 1 signal) | Mutation profonde d'objets : préférer immuabilité + nouvelle référence |
🧬 Anatomie interne — pourquoi s() est une fonction
Les signals d'Angular implémentent un algorithme push-pull :
- Push côté écriture :
set/updateinvalide les nœuds dépendants (les marque "stale") mais ne les recalcule pas immédiatement. - Pull côté lecture :
s()re-évalue uniquement si le nœud est stale, et en cascade ses dépendances.
C'est ce qui garantit :
- Pas de re-calcul si personne ne lit (lazy, économique).
- Pas de glitch (toutes les valeurs lues dans un même calcul sont cohérentes).
- Coût négligeable des
computednon lus.
set count(5)
│ invalide ─────► doubled (stale)
│ │
└── pas de calcul │
│
template lit doubled() │
│ │
└── pull ─────► doubled recompute → push valueConséquence pratique : tu peux empiler des dizaines de computed dérivés sans pénalité tant que peu de templates les lisent. C'est très différent d'un BehaviorSubject qui émet à chaque modification, indépendamment des consommateurs.
🧰 Comparaison rapide signal vs BehaviorSubject
| Critère | signal() | BehaviorSubject |
|---|---|---|
| Lecture courante | s() | bs.value ou bs.subscribe(...) |
| Synchrone | ✅ | ✅ |
| Dérivation | computed() (graph auto) | combineLatest/map (manuel) |
| Cleanup | auto via DestroyRef | manuel (subscription) |
| Glitch-free | ✅ | ❌ (à orchestrer) |
| Zoneless-friendly | ✅ | ⚠️ requiert runInsideAngular |
| Interop RxJS | toObservable() | natif |
| Async pipelines | via toObservable + opérateurs | natif |
Règle simple : état UI → signal, flux asynchrone → RxJS, et toSignal pour les terminer.
🔗 Liens
- Angular Docs — Signals
- Angular Docs — RxJS interop
- Angular Docs — Resource API
- Angular Docs — Zoneless
- RFC Signals (GitHub angular/angular#49090)
🏋️ Exercices
Progression : implémenter → rendre production-grade → casser puis réparer. Fais-les dans l'ordre, chacun s'appuie sur le précédent.
Exercice 1 — Mini-store typé 100 % signals (implémenter)
Objectif : construire un TodoStore en signals avec sélecteurs dérivés et filtre, sans RxJS ni NgRx.
Spécifications :
add(text),toggle(id),remove(id),setFilter('all' | 'active' | 'done').visible=computedfiltré selonfilter.remaining=computeddu nombre d'actifs.allDone=computed<boolean>.- Persistance localStorage via un seul
effect.
Indice/Solution : un signal<{ todos: Todo[]; filter: Filter }> privé, des computed publics qui lisent state(), mutations en state.update(s => ({ ...s, ... })). L'effect lit state() et écrit localStorage — il tracke automatiquement. Restaure dans la valeur initiale du signal (fonction restore() dans un try/catch).
Exercice 2 — Recherche live debouncée, frontière RxJS↔signal (production-grade)
Objectif : recherche avec debounce 300 ms, cancel des requêtes obsolètes, état loading/error/empty, le tout exposé en signals au template.
Spécifications :
- Input lié à
query = signal(''). - Pipeline RxJS :
toObservable(query)→debounceTime(300)→distinctUntilChanged()→switchMap(api.search). - Résultat dans
results = toSignal(...). - Gère explicitement les états : pas de requête sur chaîne vide, spinner pendant le fetch, message d'erreur récupérable.
Indice/Solution : le switchMap annule la requête précédente (cancel gratuit). Pour les états, soit rxResource({ params: () => query(), stream }) (v20 ; request/loader en v19) (qui expose status()/isLoading()/error() nativement), soit un materialize dans le pipe pour ne jamais casser le stream sur erreur. Le piège : ne pas mettre l'appel HTTP dans un computed (il doit rester pur et synchrone).
Exercice 3 — Streaming de tokens LLM sous zoneless (production-grade)
Objectif : reproduire l'AgentStreamService de la section IA, mais en mode zoneless réel (provideZonelessChangeDetection()), et prouver qu'il n'y a aucun jank à 100 tokens/s.
Spécifications :
- Buffer append-only en
signal<ChatMessage[]>, coalescence rAF. - Bouton Stop câblé à
AbortController. computed(isStreaming)qui pilote l'état du bouton.- Bench : compteur de repaints (
ngDevMode+afterRender) → doit rester ≈ FPS, pas N×tokens.
Indice/Solution : sans Zone.js, c'est le set/update du signal qui déclenche la CD — pas le requestAnimationFrame lui-même. Vérifie avec afterRender(() => repaints++) que le nombre de rendus ≈ frames affichées. Si tu vois N rendus par seconde = ton flush n'est pas coalescé (tu fais un set par token au lieu d'un par frame).
Exercice 4 — Casser le glitch-free, puis le réparer (break-then-fix)
Objectif : provoquer un cycle NG0600 (écriture dans une dépendance lue), observer le crash, puis refactorer proprement.
Spécifications :
- Écris un
effectqui faittotal.set(price() * qty())oùtotalest lu ailleurs dans uncomputeddont l'effect dépend indirectement. Observe leRuntimeError(ou la boucle). - Puis : supprime l'effect, remplace
totalpar uncomputed(() => price() * qty()). - Variante : un
selecteddérivé d'une liste mais éditable par l'utilisateur — casse-le avec uneffectqui réécritselected, répare-le aveclinkedSignal.
Indice/Solution : la règle « jamais calculer dans un effect » est exactement ce que ce cycle viole. La dérivée pure → computed. La dérivée éditable (suit une source mais autorise un override) → linkedSignal({ source, computation }). Si tu as besoin d'untracked() pour « casser » la dépendance dans l'effect, c'est presque toujours le signe qu'un computed/linkedSignal était le bon outil.
Exercice 5 — Égalité personnalisée sous charge (break-then-fix)
Objectif : un dashboard temps réel qui reconstruit un objet identique à chaque tick WebSocket re-rend tout l'écran. Trouve la cause, corrige avec equal.
Spécifications :
positions = signal<Position[]>(...)mis à jour 4×/s, même contenu mais nouvelle référence.- Mesure que les
computedaval se recalculent à chaque tick même quand les données sont identiques. - Corrige : passe une fonction
equal(comparaison structurelle ou par hash) au signal source ou aux computed.
Indice/Solution : Object.is (défaut) voit deux objets {a:1} distincts comme différents. Soit tu passes { equal: (a, b) => deepEqual(a, b) }, soit tu ne reconstruis l'objet que s'il a vraiment changé en amont (préférable — l'égalité structurelle profonde coûte cher à chaque comparaison). Mesure avant/après avec un compteur dans le computed.
🎤 En entretien
Q1 — Quelle est la différence fondamentale entre un signal et un BehaviorSubject pour de l'état d'UI ? Le signal est pull-based et glitch-free : computed ne recalcule qu'à la lecture et seulement si une dépendance a réellement changé, et toutes les valeurs lues dans un même calcul sont cohérentes. Le BehaviorSubject émet à chaque next indépendamment des consommateurs, et combiner plusieurs sources (combineLatest) peut exposer des états intermédiaires incohérents (glitches). Le signal se nettoie tout seul (DestroyRef), le Subject exige une désinscription manuelle.
Q2 — Pourquoi effect n'est-il pas le bon endroit pour calculer une valeur dérivée ? Parce qu'un effect est conçu pour les effets de bord, pas pour produire une valeur. Y faire signal.set() ré-ouvre les cycles (lecture/écriture sur la même dépendance → NG0600 ou boucle), perd la garantie glitch-free, et n'expose aucune valeur de retour exploitable. La dérivée pure va dans computed (paresseux, mémoïsé) ; la dérivée éditable dans linkedSignal. Si on se surprend à utiliser untracked() pour casser une dépendance dans un effect, c'est le signe qu'un computed était le bon outil.
Q3 — En quoi les signals préparent-ils le mode zoneless, et qu'est-ce qui casse à la migration ? En zoneless (provideZonelessChangeDetection(), stable en v20), il n'y a plus de monkey-patching d'API async par Zone.js : c'est la mutation d'un signal (set/update), un évènement template, ou markForCheck qui déclenche la détection de changement. Ce qui casse : les libs tierces qui supposaient Zone.js pour repeindre après un callback async (vieux composants Material, certaines libs de date), et le code qui mutait du state hors d'un signal en comptant sur la zone pour rafraîchir. La règle de migration : tout état d'UI passe en signal (1 BehaviorSubject = 1 signal), les flux async restent RxJS et atterrissent dans un signal via toSignal.
Q4 — Sur une UI qui streame des tokens LLM à 60/s, comment éviter le jank, et comment garantir qu'un bouton Stop arrête vraiment la génération ? Côté rendu : coalescer les tokens dans un buffer hors signal et ne faire qu'un seul set/update par frame via requestAnimationFrame — sinon on déclenche des centaines de cycles de CD par seconde. En zoneless, c'est le set final qui repeint, pas le rAF. Côté annulation : un AbortController côté client dont le signal rompt le fetch, relayé jusqu'au serveur — NestJS détecte la déconnexion et passe le même AbortSignal au SDK Anthropic (client.messages.stream({ signal })), ce qui coupe la génération et arrête de facturer des tokens. Un Stop qui ne fait que masquer l'UI sans couper le stream serveur est un bug de coût, pas une feature.
📌 Récap final
- Signal = valeur typée, traçable à la lecture, mutable via
set/update. C'est la primitive de réactivité d'Angular 16+. computed= dérivée pure, recalculée à la demande, glitch-free.effect= effet de bord ; ne pas y faire de calcul ni d'écriture sur ses dépendances.untracked= lire sans s'abonner, indispensable pour éviter les boucles.linkedSignal(v19+) = état dérivé éditable ;resource/rxResource(v19+) = données asynchrones avec cancel automatique.- Interop RxJS =
toSignal/toObservable. Garder RxJS pour la pipeline async (debounce, switchMap), garder signals pour l'état UI. - Zoneless stable en v20 = les signals deviennent le mécanisme de détection de changement par défaut ; Zone.js devient optionnel.
- Règle de pouce : si tu hésites entre
signaletBehaviorSubjectpour de l'état d'UI en 2026, c'est signal.