Signals vs RxJS — l'arbre de décision en 2026
TL;DR — Signals pour le state synchrone local et dérivé. RxJS pour les flux async multi-valeurs (HTTP, WebSocket, événements agrégés). Le pont :
toSignal(observable$, { initialValue })ettoObservable(signal). Cleanup :takeUntilDestroyed(destroyRef). Anti-pattern absolu : uneffect()qui modifie un autre signal. Pour les data fetchings :resource()(Angular 19 stable) ouhttpResource()(Angular 20). On ne migre pas RxJS vers Signals — on complète.
🧠 Mental model — ASCII + analogie
Signals = cellules de tableur. Vous tapez =A1+B1 dans C1 : C1 se met à jour automatiquement quand A1 ou B1 change. Pas de subscribe, pas de unsubscribe, pas de stream. Pull, synchrone, glitch-free.
RxJS = tapis roulant d'événements. Une valeur arrive quand elle arrive, peut être asynchrone, peut être annulée, peut errer, peut compléter. Push, temporal.
Signals (synchronous, pull) RxJS (async, push, multi-value)
───────────────────────── ────────────────────────────────
count = signal(0) click$ = fromEvent(btn, 'click')
double = computed(() => count() * 2) debounced$ = click$.pipe(
debounceTime(300),
effect(() => render(double())) switchMap(...),
)
count.set(1) debounced$.subscribe(...)
// └→ double recalc → effect run
┌─────────┐ ┌──────────┐ ──●─●──●─●──●──●──>
│ count │───→│ computed │
└─────────┘ └────┬─────┘ Cancellation built-in
│ Backpressure built-in
▼ Async natif
┌─────────┐ Pas de "valeur courante"
│ effect │
└─────────┘Analogie pratique — vous gérez un dashboard :
- Le filtre de date (start/end) est un signal. C'est synchrone, local, dérivé en plein de choses.
- Les données API sous ce filtre arrivent par HTTP. C'est un Observable (ou un
httpResourcequi les enveloppe). - Le WebSocket qui pousse les updates live est un Observable, sans hésitation.
- Le count de résultats affiché est un
computed(() => results().length).
Mélanger les deux n'est pas seulement permis : c'est la norme.
Comment ça marche vraiment — push pour invalider, pull pour calculer
La phrase « signals = pull, RxJS = push » est vraie en surface mais cache l'astuce d'implémentation qui fait la performance. Un staff doit savoir l'expliquer.
Un signal n'embarque pas de liste de subscribers façon Observer pattern naïf. Le graphe est construit au moment de la lecture : quand un computed/effect s'exécute, Angular enregistre quels signals ont été lus pendant cette exécution (dépendances dynamiques — la liste change à chaque run si vous avez un if). Chaque nœud porte une version (un compteur monotone).
set() ──push──► marque les dépendants "dirty" (propagation O(profondeur), pas de recalcul)
│
read() ──pull──► si dirty : compare la version des sources
├─ version inchangée ──► réutilise la valeur memoïzée (skip)
└─ version changée ──► recalcule, met à jour sa propre versionDeux conséquences que les juniors ratent :
- Lazy + memoïzé : un
computedjamais lu ne se calcule jamais, même si ses sources changent 1000 fois. Le coût est payé auread(), pas auset(). C'est l'inverse decombineLatest, qui recalcule à chaque émission qu'il y ait un consommateur ou non. - Equality-checking en cascade (« value-based dirty checking ») : si
achange mais quecomputed(() => a() > 0)renvoie la même valeur (true→true), les dépendants de ce computed ne sont pas recalculés — la propagation s'arrête net. Un changement deapeut donc ne déclencher aucun re-render.combineLatestn'a pas cette coupure ; il réémet.
C'est aussi ce qui rend les signals glitch-free (voir section dédiée plus bas) : la phase push ne fait que marquer, jamais calculer, donc aucun consommateur ne peut observer un état à moitié propagé.
🛠️ Code minimal (ts + html)
Les briques Signal
import { signal, computed, effect, untracked } from '@angular/core';
// signal — état writable
const count = signal(0);
count(); // lit
count.set(1); // remplace
count.update(c => c + 1); // transforme
// count.mutate() a été RETIRÉ en Angular 17. Utilisez update.
// computed — dérivé memoïzé, recalcul paresseux
const double = computed(() => count() * 2);
// effect — side effect réactif (logging, sync localStorage, etc.)
effect(() => {
console.log('count is', count());
});
// untracked — lire un signal SANS créer de dépendance
effect(() => {
console.log(count()); // dépendance
const snapshot = untracked(() => other()); // pas une dépendance
});Les briques Interop
import { toSignal, toObservable, outputFromObservable, outputToObservable } from '@angular/core/rxjs-interop';
// Observable -> Signal
const user = toSignal(http.get<User>('/me'), { initialValue: null });
// ou avec requireSync si la source émet synchroniquement
const route = toSignal(activatedRoute.params, { requireSync: true });
// Signal -> Observable
const count = signal(0);
const count$ = toObservable(count); // émet à chaque changement
count$.pipe(debounceTime(300)).subscribe(/* ... */);
// outputFromObservable / outputToObservable — pour les @Output
// Angular 17.3+
class TimerComponent {
tick = outputFromObservable(interval(1000));
}Cleanup automatique
@Component({ /* ... */ })
export class Foo {
private destroyRef = inject(DestroyRef);
// Dans un injection context (constructor, field initializer),
// toSignal et toObservable utilisent automatiquement le DestroyRef ambiant.
data = toSignal(http.get('/x'), { initialValue: null });
// En dehors du contexte d'injection, passez-le explicitement :
init() {
interval(1000).pipe(
takeUntilDestroyed(this.destroyRef),
).subscribe();
}
}🎯 Patterns courants
1. Filter form en Signal, HTTP en Observable, résultat en Signal
@Component({ /* ... */ })
export class UserListComponent {
private http = inject(HttpClient);
// State synchrone -> signals
search = signal('');
page = signal(1);
// Dérive un Observable des signals
private query$ = toObservable(computed(() => ({
q: this.search(),
page: this.page(),
}))).pipe(
debounceTime(300),
distinctUntilChanged((a, b) => a.q === b.q && a.page === b.page),
);
// HTTP -> Observable -> Signal
users = toSignal(
this.query$.pipe(
switchMap(({ q, page }) => this.http.get<User[]>(`/users?q=${q}&page=${page}`).pipe(
catchError(() => of([])),
)),
),
{ initialValue: [] },
);
count = computed(() => this.users().length);
}<input [value]="search()" (input)="search.set($any($event.target).value)">
<p>{{ count() }} users</p>
@for (u of users(); track u.id) {
<user-card [user]="u" />
}2. Resource API (Angular 19 stable, 20 GA)
import { resource } from '@angular/core';
@Component({ /* ... */ })
export class UserDetail {
id = input.required<string>();
user = resource({
request: () => ({ id: this.id() }), // re-fetch quand id change
loader: async ({ request, abortSignal }) => {
const res = await fetch(`/api/users/${request.id}`, { signal: abortSignal });
if (!res.ok) throw new Error('failed');
return res.json() as Promise<User>;
},
});
// user.value() : User | undefined
// user.status() : 'idle' | 'loading' | 'resolved' | 'error'
// user.error() : unknown
// user.reload()
}resource() est l'équivalent signal-first de RxJS pour le data fetching avec cancellation. L'abortSignal annule automatiquement quand le request change ou quand le composant est détruit.
3. httpResource — Angular 20
import { httpResource } from '@angular/common/http';
user = httpResource<User>(() => ({
url: `/api/users/${this.id()}`,
method: 'GET',
}));httpResource est le mariage HttpClient + resource() — pas besoin d'écrire le loader, vous décrivez la requête sous forme de signal. Renvoie un HttpResourceRef<T> avec .value(), .status(), .error(), .reload(). Il passe par les interceptors HttpClient (auth, retry, logging) — un avantage majeur sur un fetch() brut dans un resource().
Production concerns — ce qu'un staff vérifie avant de mettre resource/httpResource en prod
| Préoccupation | resource / httpResource | Comment le gérer |
|---|---|---|
| Cancellation des requêtes obsolètes | Automatique (abortSignal re-déclenché quand le request/params change) | Rien à faire — c'est le gain principal vs toSignal(http$) qui ne cancelle pas |
| Retry / backoff | Pas intégré | Mettre la policy dans un interceptor HttpClient (httpResource) ou retry({ delay }) dans le loader (rxResource) |
| État d'erreur | status() === 'error', error() typé unknown | Garder la dernière valeur valide : value() reste l'ancienne donnée pendant un reload échoué (UX « stale-while-revalidate ») |
| SSR / hydration | Intégré au TransferState via provideClientHydration() | Sans hydration, double-fetch (serveur + client). Activez withHttpTransferCacheOptions |
| Refetch manuel / mutation | .reload() | Après un POST/PUT, appelez .reload() ou utilisez un resource séparé pour la mutation |
| Debounce de l'input | Pas intégré | Le request réagit immédiatement. Pour debounce, dérivez le request d'un signal lui-même mis à jour en différé, ou restez sur le pattern toObservable + debounceTime + switchMap + toSignal |
| Pas de valeur initiale | value() est undefined avant la 1ʳᵉ résolution | Toujours gérer l'état idle/loading dans le template (@if (r.isLoading())) |
Règle staff : httpResource est votre défaut pour un GET réactif (fetch piloté par des params signal). Dès qu'il faut du debounce, du multiplexage de sources, ou un transport non-HTTP (WS/SSE), revenez à RxJS et faites le pont avec toSignal. Ne tordez pas httpResource pour faire ce que switchMap fait nativement.
4. Form state + derived validation
export class CheckoutForm {
email = signal('');
password = signal('');
emailValid = computed(() => /^[^@]+@[^@]+\.[^@]+$/.test(this.email()));
passwordValid = computed(() => this.password().length >= 8);
formValid = computed(() => this.emailValid() && this.passwordValid());
}Zéro RxJS, et pourtant entièrement réactif.
5. WebSocket live + signal snapshot
export class LiveQuotes {
private ws = inject(WebSocketService);
quotes = toSignal(
this.ws.connect<Quote>('/quotes').pipe(
scan((acc, q) => ({ ...acc, [q.symbol]: q }), {} as Record<string, Quote>),
),
{ initialValue: {} },
);
// Lecture synchrone dans un computed
apple = computed(() => this.quotes()['AAPL']);
}🔄 Versions — Angular 16 → 20
| Version | Apport Signals / RxJS interop |
|---|---|
| 16 | Signals en preview : signal(), computed(), effect(). toSignal, toObservable. takeUntilDestroyed. |
| 17 | Signals stable (Nov 2023). mutate() retiré. signal({ equal }) custom equality. Inputs signal en developer preview. |
| 17.3 | input(), output(), model(), viewChild(), contentChild() signal-based en preview. outputFromObservable, outputToObservable. |
| 18 | Signal inputs / queries stable. Zoneless preview. effect() re-géré pour éviter les loops via les writes par défaut interdits. |
| 19 | resource() API stable. linkedSignal() (writable computed). Zoneless toujours preview. Effect schedulers configurables. |
| 20 | Zoneless GA. httpResource() stable. Recommandation officielle de l'équipe Angular : Signals first pour le state, RxJS pour l'async multi-valeurs. |
linkedSignal — le chaînon manquant
import { linkedSignal } from '@angular/core';
// Forme longue : accès à la valeur précédente pour préserver la sélection
const selected = linkedSignal({
source: this.items, // re-init quand items change
computation: (items, previous) =>
items.some((i) => i === previous?.value) ? previous!.value : items[0],
});
selected.set(otherItem); // writable
// Forme courte (Angular 19+) : juste source → valeur, sans previous
const firstItem = linkedSignal(() => this.items()[0]);C'est un computed writable qui se ré-initialise quand sa source change — utile pour les selects, les onglets actifs, etc. Le piège classique : on essayait avant de faire ça avec un effect(() => selected.set(...)) qui écrit un signal depuis un effect (boucle potentielle + ordre de CD imprévisible). linkedSignal est la primitive officielle pour « état dérivé mais qu'on peut écraser manuellement » — préférez-la toujours à l'effect-qui-set.
⚠️ Pitfalls — 10 erreurs qui mordent
effect()qui set un autre signal — historiquement Angular exigeaitallowSignalWrites: true(Angular 16-18). Cette option a été supprimée en Angular 19 : les writes dans un effect sont désormais autorisés mais restent une source de boucles infinies (Angular détecte et casse les cycles directs, pas les indirects). La règle de staff : pour dériver, utilisezcomputed(oulinkedSignalsi writable) ; leseffectsont réservés aux side effects (DOM, localStorage, log, API fire-and-forget). Si vous écrivez un signal dans un effect, demandez-vous toujours : « est-ce un side effect, ou un dérivé déguisé ? ». 9 fois sur 10, c'est uncomputedmal placé.toSignalsansinitialValue— le signal estT | undefinedjusqu'à la 1ère émission. Soit vous gérezundefined, soit vous passez{ initialValue: ... }, soit{ requireSync: true }si la source émet synchroniquement.Lire un signal dans
subscribe— ne crée pas de dépendance, c'est juste un appel de fonction. Si vous voulez réagir au signal dans un flux Rx, passez partoObservable(signal).toObservablecréé dans le template — appelertoObservable(x)puis| asyncdirectement dans le template recrée un observable à chaque change detection (et donc une nouvelle souscription à chaque CD : fuite + re-fetch en boucle si la source est HTTP). Stockez le résultat en field une seule fois :html<!-- ❌ recrée l'observable à chaque CD --> <p>{{ (toObservable(x) | async) }}</p>ts// ✅ une seule fois, en field readonly x$ = toObservable(this.x);html<p>{{ x$ | async }}</p>Mélanger
BehaviorSubjectetsignalpour le même state — choisissez. Garder les deux et synchroniser = source de bugs et de double-render. Si vous migrez progressivement, ayez une source unique de vérité (souvent le signal) et exposez l'autre viatoObservable.computedavec un side effect dedans —computed(() => { fetch(...); return ... }). Lecomputedpeut être ré-évalué à tout moment, lefetchpart en boucle. Lescomputeddoivent être purs.Oublier que
signal()compare par référence —signal({})puisset({})notifie (référence différente).signal(arr)puisarr.push(x); set(arr)ne notifie pas (même référence). Utilisezupdate(a => [...a, x])ousignal(value, { equal: lodashIsEqual }).Lire un signal dans
onPushnon-signal —OnPushtraditionnel ne se déclenche pas sur signal change tout seul dans Angular ≤ 16. Depuis 17, le runtime marque automatiquement le composant dirty quand un signal lu dans son template change. Vérifiez votre version.resource()avec unrequestnon-réactif — sirequest: () => ({ id: this.id })au lieu de() => ({ id: this.id() }), le request n'est pas un signal-read, il ne re-fetch jamais. La fonction doit lire des signals.takeUntilDestroyed()hors injection context — appel sans argument hors d'un constructor/field initializer = erreur runtime. PasseztakeUntilDestroyed(this.destroyRef).Effects qui s'attendent à voir tous les changements —
effectest glitch-free mais coalescé : si vous faites 3set()synchrones, l'effect s'exécute une seule fois avec la dernière valeur. Pour les flux complets, c'est un Observable qu'il vous faut.Croire qu'un
effectest synchrone après unset()— il ne l'est pas. Les effects sont schedulés et s'exécutent après le rendu (dans la phase de synchronisation du change detection), pas immédiatement après leset(). En test, c'est pour ça qu'il fautTestBed.flushEffects()(outick()sousfakeAsync). En prod, si vous avez besoin de lire le DOM après qu'Angular l'a peint, utilisezafterRenderEffect()/afterNextRender(), pas uneffect()classique (qui peut tourner avant que le DOM reflète l'état).Lire un signal injecté hors de tout contexte réactif —
signal()peut s'appeler partout, maiscomputed/effect/toSignal/toObservableexigent un injection context (constructor, field initializer, ourunInInjectionContext). Les appeler dans unngOnInitou un callback async lèveNG0203. Poureffect/takeUntilDestroyedhors contexte, passez l'injector/destroyRefexplicitement.
🧪 Testing — fakeAsync, TestBed.runInInjectionContext, flushEffects
Tester un signal pur
it('count and double', () => {
const count = signal(0);
const double = computed(() => count() * 2);
expect(double()).toBe(0);
count.set(5);
expect(double()).toBe(10);
});Tester un effect
import { TestBed } from '@angular/core/testing';
it('effect runs on change', () => {
TestBed.runInInjectionContext(() => {
const s = signal(0);
const spy = jasmine.createSpy();
effect(() => spy(s()));
TestBed.flushEffects(); // déclenche le 1er run
expect(spy).toHaveBeenCalledWith(0);
s.set(1);
TestBed.flushEffects();
expect(spy).toHaveBeenCalledWith(1);
});
});TestBed.flushEffects() (Angular 17+) est indispensable car les effects sont schedulés via le scheduler de change detection.
Tester un toSignal d'un Observable async
it('toSignal reflects http', fakeAsync(() => {
TestBed.runInInjectionContext(() => {
const subj = new BehaviorSubject<number>(0);
const sig = toSignal(subj);
expect(sig()).toBe(0);
subj.next(5);
expect(sig()).toBe(5);
});
}));Tester un resource
it('resource loads', fakeAsync(() => {
TestBed.runInInjectionContext(() => {
const id = signal('1');
const r = resource({
request: () => ({ id: id() }),
loader: async ({ request }) => ({ id: request.id, name: 'X' }),
});
tick();
TestBed.flushEffects();
expect(r.status()).toBe('resolved');
expect(r.value()).toEqual({ id: '1', name: 'X' });
});
}));🎬 Cas d'usage concrets
Scénario 1 — SaaS RH, migration progressive RxJS → signals
Une plateforme RH expose un module « tableaux de bord » qui empile : KPI de recrutement, graphes d'évolution, alertes. Tout était écrit en RxJS avec BehaviorSubject + combineLatest. L'équipe se rend compte que 80 % des observables sont en réalité du state synchrone (valeurs de filtres, sélection courante, totaux dérivés) qui n'avait jamais besoin d'être async. Le seul vrai flux async, ce sont les requêtes HTTP de chargement initial.
Stratégie : garder RxJS pour la couche HTTP (élégant avec switchMap, retry, forkJoin), mais convertir tout le state local et dérivé en signaux. Concrètement : selectedJob$ (BehaviorSubject) devient selectedJob (signal), kpis$ (combineLatest) devient kpis = computed(() => derive(rawData(), selectedJob())). La conversion utilise toSignal(http$, { initialValue: null }) à la frontière.
Bénéfice mesuré : −40 % de lignes de code dans les composants (plus d'async pipe, plus de souscriptions manuelles), zéro fuite mémoire constatée depuis (avant : 2-3 par mois), et la passage en OnPush devient trivial parce que les signaux marquent automatiquement les composants à rerender.
Scénario 2 — E-commerce, mix HTTP + state local
Un site e-commerce affiche une page catégorie : filtres (signal, local), résultats produits (HTTP donc RxJS), favoris utilisateur (signal partagé via service). Plutôt que de tout forcer en RxJS ou tout forcer en signaux, l'équipe combine.
- Filtres :
filters = signal({ priceMin: 0, priceMax: 500, brands: [] }). Modifications synchrones, lectures triviales. - Résultats : un
effectwatchfilters()et déclenche unswitchMapqui pousse les résultats dansresults = signal<Product[]>([]). Le HTTP reste RxJS (cancellation native parswitchMap). - Favoris :
favorites = signal<Set<string>>(new Set())exposé par unFavoritesStore, plus uneffectqui persiste dans localStorage. - Dérivés :
displayedResults = computed(() => results().map(p => ({ ...p, isFavorite: favorites().has(p.id) }))).
Le composant n'a qu'un seul subscribe (l'effect HTTP) et tout le reste est synchrone. Le code est lisible de haut en bas comme un script impératif, mais reste réactif.
Scénario 3 — Cabinet juridique, formulaires réactifs avec state signal
Un cabinet juridique a un formulaire d'intake client (90 champs, validation conditionnelle, sections dynamiques). L'équipe utilise ReactiveForms (le FormGroup typed) pour la validation et la sérialisation, mais expose la valeur courante et le statut en signaux : formValue = toSignal(form.valueChanges, { initialValue: form.value }), formStatus = toSignal(form.statusChanges, { initialValue: form.status }).
Avantage : les affichages conditionnels dans le template utilisent @if (formValue().clientType === 'company') directement, sans async pipe. Les dérivés comme « le formulaire est-il prêt à être soumis ? » deviennent un computed. Et la persistance auto-save (sauvegarde brouillon toutes les 5 secondes) se fait via effect(() => { const v = formValue(); debouncedSave(v); }).
L'équipe a découvert un piège : toSignal sans initialValue retourne T | undefined, ce qui complique les computed qui consomment la valeur. La règle adoptée : toujours fournir initialValue quand on a la valeur initiale du formulaire à portée.
🛠️ Exemple end-to-end
Use case : page catégorie e-commerce. Filtres en signal, HTTP en RxJS, résultats poussés dans un signal. Mix idiomatique sans async pipe.
// products.api.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface Product {
id: string;
name: string;
priceCents: number;
brand: string;
}
export interface ProductFilters {
priceMin: number;
priceMax: number;
brands: ReadonlyArray<string>;
}
@Injectable({ providedIn: 'root' })
export class ProductsApi {
private readonly http = inject(HttpClient);
search(filters: ProductFilters): Observable<Product[]> {
let params = new HttpParams()
.set('priceMin', filters.priceMin)
.set('priceMax', filters.priceMax);
for (const b of filters.brands) params = params.append('brand', b);
return this.http.get<Product[]>('/api/products', { params });
}
}// favorites.store.ts
import { Injectable, computed, effect, signal } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class FavoritesStore {
private readonly _ids = signal<ReadonlySet<string>>(this.restore());
readonly ids = this._ids.asReadonly();
readonly count = computed(() => this._ids().size);
constructor() {
effect(() => localStorage.setItem('favs', JSON.stringify([...this._ids()])));
}
toggle(id: string): void {
this._ids.update((set) => {
const next = new Set(set);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
private restore(): Set<string> {
try {
const raw = localStorage.getItem('favs');
return new Set(raw ? (JSON.parse(raw) as string[]) : []);
} catch {
return new Set();
}
}
}// category.page.ts
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CurrencyPipe } from '@angular/common';
import { switchMap } from 'rxjs';
import { ProductsApi, ProductFilters, Product } from './products.api';
import { FavoritesStore } from './favorites.store';
@Component({
selector: 'app-category',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CurrencyPipe],
template: `
<header>
<label>Prix max
<input
type="number"
[value]="filters().priceMax"
(input)="setMax($any($event.target).valueAsNumber)"
/>
</label>
<p>{{ favs.count() }} favoris</p>
</header>
@if (loading()) {
<p>Chargement…</p>
}
<ul>
@for (p of displayed(); track p.id) {
<li>
{{ p.name }} ({{ p.brand }}) — {{ p.priceCents / 100 | currency: 'EUR' }}
<button (click)="favs.toggle(p.id)">
{{ p.isFavorite ? '★' : '☆' }}
</button>
</li>
}
</ul>
`,
})
export class CategoryPage {
private readonly api = inject(ProductsApi);
protected readonly favs = inject(FavoritesStore);
protected readonly filters = signal<ProductFilters>({
priceMin: 0,
priceMax: 500,
brands: [],
});
protected readonly loading = signal(false);
protected readonly results = signal<Product[]>([]);
protected readonly displayed = computed(() =>
this.results().map((p) => ({ ...p, isFavorite: this.favs.ids().has(p.id) })),
);
constructor() {
// Frontière signal → RxJS : convertit filters() en flux, lance les requêtes
effect((onCleanup) => {
const current = this.filters();
this.loading.set(true);
const sub = this.api
.search(current)
.pipe(takeUntilDestroyed())
.subscribe({
next: (products) => {
this.results.set(products);
this.loading.set(false);
},
error: () => this.loading.set(false),
});
onCleanup(() => sub.unsubscribe());
});
}
protected setMax(value: number): void {
this.filters.update((f) => ({ ...f, priceMax: value }));
}
}Le composant n'utilise aucun async pipe. Les filtres sont synchrones (signal), les favoris partagés via signal, et l'unique flux async (HTTP) est encapsulé dans un effect qui s'abonne et nettoie automatiquement. Le computed displayed recalcule paresseusement à chaque changement de results ou de favs.ids.
Lecture staff — l'
effect+subscribeci-dessus est volontairement pédagogique, mais ce n'est pas le code que vous livreriez en prod. Il mélange flux et état (le pattern même que le pitfall « subscribe dans un effect » déconseille) : gestion manuelle deloading, de l'erreur, et fenêtre de race si deux filtres changent en < 1 RTT (letakeUntilDestroyedne coupe pas la requête précédente, seulswitchMaple ferait). La version production se réécrit enrxResource(Exercice 3) :tsimport { rxResource } from '@angular/core/rxjs-interop'; protected readonly products = rxResource({ request: () => this.filters(), // re-fetch réactif quand filters() change stream: ({ request }) => this.api.search(request), }); // products.value() | products.isLoading() | products.error() | products.reload() // cancellation automatique de la requête obsolète via l'abortSignal interneVous récupérez le
loading, l'error, la cancellation et la déduplication gratuitement, et vous supprimez l'effect/subscribe. Gardez le patterneffect+subscribeuniquement quand le flux n'est pas un simple fetch request→response (ex. WebSocket multiplexé).
🔁 Quand utiliser / éviter
Signals pour :
- State synchrone local (form values, toggles, sélection, pagination).
- Dérivés (total, count, formatage, validation).
- DOM-related state lu pendant le rendu.
- Form Signals (Angular 20 introduit progressivement les Forms signal-based).
RxJS pour :
- HTTP (sauf si vous utilisez
httpResource). - WebSockets, SSE, événements DOM agrégés (drag, scroll, keypress combo).
- Multi-source combination complexe (
combineLatest,forkJoin). - Cancellation d'opérations en chaîne (
switchMap). - Backpressure (debounce, throttle, audit, sample).
Évitez de mélanger :
- Garder un state à la fois dans un
BehaviorSubjectet un signal — choisissez. - Mettre un
subscribedans uneffect— chaînez viatoObservableou utilisezresource. - Convertir un signal vers Observable juste pour
subscribeetsetun autre signal — utilisezcomputedoueffectdirectement.
Arbre de décision
Mon état est…
├── Synchrone, lu dans le template → signal / computed
├── Dérivé d'autres signals → computed
├── Une réaction à un changement (DOM, log) → effect
├── Une requête HTTP → resource / httpResource / toSignal(http$)
├── Un WebSocket / SSE → Observable + toSignal
├── Un événement DOM agrégé (debounce, etc.) → fromEvent + RxJS + toSignal
├── Une combinaison multi-sources complexe → RxJS (combineLatest) puis toSignal
└── Une Promise unique sans cancel utile → resource ou await direct🧰 Patterns d'architecture mixtes
State service signal-first avec exposition Observable
@Injectable({ providedIn: 'root' })
export class CartService {
// Source unique : un signal
private _items = signal<CartItem[]>([]);
// Lecture publique : signal readonly
items = this._items.asReadonly();
count = computed(() => this._items().length);
total = computed(() => this._items().reduce((s, i) => s + i.price, 0));
// Pour le code legacy qui attend un Observable
items$ = toObservable(this._items);
add(item: CartItem) {
this._items.update(arr => [...arr, item]);
}
remove(id: string) {
this._items.update(arr => arr.filter(i => i.id !== id));
}
clear() {
this._items.set([]);
}
}Cette architecture donne un seul writable (_items), exposé en lecture sous deux formes (signal readonly et Observable). Le composant choisit sa forme préférée.
Migration progressive d'un BehaviorSubject vers signal
// AVANT
@Injectable({ providedIn: 'root' })
export class UserService {
private user$ = new BehaviorSubject<User | null>(null);
user$ = this.user$.asObservable();
setUser(u: User) { this.user$.next(u); }
get current() { return this.user$.value; }
}
// APRÈS (étape 1 : doublon temporaire)
@Injectable({ providedIn: 'root' })
export class UserService {
private _user = signal<User | null>(null);
user = this._user.asReadonly(); // nouveau API signal
user$ = toObservable(this._user); // ancien API Observable, gardé pour compat
setUser(u: User) { this._user.set(u); }
get current() { return this._user(); }
}
// APRÈS (étape 2 : tout migré, on retire l'Observable)
@Injectable({ providedIn: 'root' })
export class UserService {
private _user = signal<User | null>(null);
user = this._user.asReadonly();
setUser(u: User) { this._user.set(u); }
}Combiner signals et RxJS — pattern "trigger + result"
@Component({ /* ... */ })
export class SearchPage {
// Inputs : signals
query = signal('');
filter = signal<Filter>({ tag: null, sort: 'date' });
// Trigger combiné : signal -> observable (debounce + cancel)
private trigger$ = toObservable(computed(() => ({
q: this.query(),
f: this.filter(),
}))).pipe(
debounceTime(300),
distinctUntilChanged((a, b) =>
a.q === b.q && a.f.tag === b.f.tag && a.f.sort === b.f.sort,
),
);
// Résultat : observable -> signal pour template
result = toSignal(
this.trigger$.pipe(
switchMap(({ q, f }) => this.api.search(q, f).pipe(
catchError(() => of([])),
)),
),
{ initialValue: [] },
);
// Dérivé pur
isEmpty = computed(() => this.result().length === 0);
}C'est le pattern canonique pour combiner les deux mondes : signal d'entrée, RxJS pour la logique temporelle, signal de sortie pour le template.
🆚 Tableau comparatif détaillé
| Critère | Signal | Observable |
|---|---|---|
| Sync/Async | Synchrone (pull) | Async (push) |
| Valeur courante | Toujours (lecture via ()) | Optionnel (BehaviorSubject seulement) |
| Cancellation | N/A (pas de notion de stream) | First-class (unsubscribe, takeUntil...) |
| Combinaison multi-sources | computed() (simple) | combineLatest, forkJoin, etc. |
| Backpressure | Coalescing automatique | Manuel (debounce, throttle) |
| Glitch-free | Oui (toujours cohérent) | Non par défaut |
| Async data fetch | Via resource() ou toSignal(http$) | Direct (HttpClient) |
| Template binding | lecture directe x() | pipe async sur x$ |
| OnPush triggering | Auto (signal-aware CD) | Via async pipe ou markForCheck |
| Cleanup | DestroyRef auto | takeUntilDestroyed ou async pipe |
| Testing | flushEffects, lecture directe | Marble, fakeAsync, firstValueFrom |
| Courbe d'apprentissage | Faible | Élevée |
| Maturité écosystème | Récente (2023+) | 10+ ans |
🧠 Approfondissement — glitch-free, coalescing
Considérez ce graphe de dépendances :
a ──┐
├──> sum = a + b
b ──┤
└──> mean = (a + b) / 2
computed(() => sum()) computed(() => mean())Si vous faites a.set(10); b.set(20); :
- RxJS (Subject + combineLatest) émettrait potentiellement deux fois — une fois avec
a=10, b=old, puis aveca=10, b=20. C'est le "glitch". - Signals : les deux
setsont batchés, l'effect/computed downstream voit l'état final cohérent une seule fois.
Cette garantie est précieuse pour les UIs complexes : pas de flicker, pas d'état intermédiaire visible.
🎓 Cas réels — études de migration
Cas 1 : Page de profil utilisateur
Avant (RxJS pur) :
@Component({ /* ... */ })
export class ProfilePage implements OnInit, OnDestroy {
user$ = new BehaviorSubject<User | null>(null);
edit$ = new BehaviorSubject<boolean>(false);
destroy$ = new Subject<void>();
ngOnInit() {
this.route.params.pipe(
switchMap(p => this.api.getUser(p.id)),
takeUntil(this.destroy$),
).subscribe(u => this.user$.next(u));
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}Après (signal-first) :
@Component({ /* ... */ })
export class ProfilePage {
private route = inject(ActivatedRoute);
private api = inject(UserApi);
id = toSignal(this.route.params.pipe(map(p => p.id)), { requireSync: true });
user = httpResource<User>(() => `/api/users/${this.id()}`);
edit = signal(false);
fullName = computed(() => `${this.user.value()?.first} ${this.user.value()?.last}`);
}3 lignes au lieu de 15, pas de destroy, pas de subscribe, pas de BehaviorSubject. C'est l'attractivité des Signals.
Cas 2 : Search avec autocomplete
Avant :
@Component({ /* ... */ })
export class SearchCmp {
query = new FormControl('', { nonNullable: true });
results$ = this.query.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.api.search(q)),
);
}Après (hybrid) :
@Component({ /* ... */ })
export class SearchCmp {
query = signal('');
results = toSignal(
toObservable(this.query).pipe(
debounceTime(300),
distinctUntilChanged(),
switchMap(q => this.api.search(q)),
),
{ initialValue: [] },
);
}Le RxJS reste où il est utile (debounce, switchMap). Le state synchrone passe en signal.
🤖 Signals + RxJS pour une UI d'agent IA (streaming LLM)
C'est le cas où les deux mondes se rencontrent vraiment. Un chat d'agent IA streame des tokens (flux temporel → RxJS ou ReadableStream), mais l'UI a besoin d'un état synchrone cohérent lu pendant le rendu (la liste des messages, l'état du bouton Stop, le statut de chaque tool-call → signals). Voici l'architecture de référence en zoneless (Angular 20).
Mental model — qui possède quoi
SSE / fetch ReadableStream Signals (état UI, lu au rendu)
──────────────────────── ───────────────────────────────
token "Hel" "lo" " world" ──push──> messages = signal<Msg[]>([...])
event: tool_use ──push──> toolTrace = signal<ToolStep[]>([...])
event: done ──push──> status = signal<'idle'|'streaming'|...>
assistantText = computed(join buffer)
AbortController ◀── Stop button (signal) ── ET ── serveur (annule la génération)Règle d'or : le transport est un flux (push, annulable, faillible) → on le consomme avec RxJS ou un getReader(). L'état affiché est synchrone → signals. On ne stocke jamais le buffer de tokens dans un Observable lu par | async : on l'accumule dans un signal append-only, et le template lit assistantText().
Streaming via fetch + ReadableStream (recommandé pour SSE POST)
EventSource ne fait que du GET et ne porte pas de body — or un appel d'agent envoie un body (messages, system, tools). On utilise donc fetch + getReader() + TextDecoder, avec un AbortController câblé au bouton Stop.
import {
ChangeDetectionStrategy, Component, signal, computed, inject, DestroyRef,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
interface ChatMessage {
readonly id: string;
readonly role: 'user' | 'assistant';
readonly text: string;
}
// Discriminated union — la timeline d'un agent, état par état
type ToolStep =
| { kind: 'pending'; id: string; name: string }
| { kind: 'running'; id: string; name: string; args: unknown }
| { kind: 'streaming'; id: string; name: string; partial: string }
| { kind: 'done'; id: string; name: string; result: unknown }
| { kind: 'error'; id: string; name: string; error: string };
@Component({
selector: 'app-agent-chat',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ul class="messages">
@for (m of messages(); track m.id) {
<li [class.assistant]="m.role === 'assistant'">{{ m.text }}</li>
}
</ul>
<!-- Timeline de tool-calls, typée par discriminant -->
<ol class="trace">
@for (step of toolTrace(); track step.id) {
@switch (step.kind) {
@case ('pending') { <li>⏳ {{ step.name }}</li> }
@case ('running') { <li>▶️ {{ step.name }}…</li> }
@case ('streaming') { <li>… {{ step.partial }}</li> }
@case ('done') { <li>✅ {{ step.name }}</li> }
@case ('error') { <li>❌ {{ step.name }}: {{ step.error }}</li> }
}
}
</ol>
@if (status() === 'streaming') {
<button (click)="stop()">Stop</button>
} @else {
<button (click)="send()" [disabled]="status() === 'idle' && !draft()">Envoyer</button>
}
`,
})
export class AgentChatComponent {
private readonly destroyRef = inject(DestroyRef);
protected readonly draft = signal('');
protected readonly messages = signal<ChatMessage[]>([]);
protected readonly toolTrace = signal<ToolStep[]>([]);
protected readonly status = signal<'idle' | 'streaming' | 'error'>('idle');
// L'assistant en cours d'écriture : un buffer append-only dérivé
protected readonly assistantText = computed(
() => this.messages().findLast((m) => m.role === 'assistant')?.text ?? '',
);
private controller: AbortController | null = null;
// Coalescing des tokens sous zoneless : on n'écrit le signal qu'une fois par frame
private pendingTokens = '';
private rafId: number | null = null;
async send(): Promise<void> {
const userMsg: ChatMessage = { id: crypto.randomUUID(), role: 'user', text: this.draft() };
const assistantId = crypto.randomUUID();
this.messages.update((m) => [
...m,
userMsg,
{ id: assistantId, role: 'assistant', text: '' },
]);
this.draft.set('');
this.status.set('streaming');
this.controller = new AbortController();
try {
const res = await fetch('/api/agent/stream', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ messages: this.messages() }),
signal: this.controller.signal,
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// Parse SSE : événements séparés par "\n\n"
const events = buf.split('\n\n');
buf = events.pop() ?? '';
for (const evt of events) this.handleSseEvent(evt, assistantId);
}
this.status.set('idle');
} catch (e) {
// AbortError = Stop volontaire, pas une erreur
if ((e as Error).name !== 'AbortError') this.status.set('error');
else this.status.set('idle');
} finally {
this.controller = null;
}
}
stop(): void {
// Annule côté CLIENT (coupe le reader)…
this.controller?.abort();
// …le serveur reçoit le close du stream et doit abort sa génération LLM (voir NestJS).
}
private handleSseEvent(raw: string, assistantId: string): void {
const dataLine = raw.split('\n').find((l) => l.startsWith('data:'));
if (!dataLine) return;
const payload = JSON.parse(dataLine.slice(5).trim()) as
| { type: 'token'; text: string }
| { type: 'tool'; step: ToolStep };
if (payload.type === 'token') {
this.pendingTokens += payload.text;
this.scheduleFlush(assistantId); // rAF-coalescé : 1 write/frame, pas 1/token
} else if (payload.type === 'tool') {
const step = payload.step;
this.toolTrace.update((trace) => {
const i = trace.findIndex((s) => s.id === step.id);
return i === -1 ? [...trace, step] : trace.with(i, step);
});
}
}
// Sous zoneless, écrire un signal par token (60+/s) = autant de CD.
// On coalesce avec requestAnimationFrame : ~60 fps max, fluide.
private scheduleFlush(assistantId: string): void {
if (this.rafId !== null) return;
this.rafId = requestAnimationFrame(() => {
const chunk = this.pendingTokens;
this.pendingTokens = '';
this.rafId = null;
if (!chunk) return;
this.messages.update((msgs) => {
const i = msgs.findIndex((m) => m.id === assistantId);
if (i === -1) return msgs;
return msgs.with(i, { ...msgs[i], text: msgs[i].text + chunk });
});
});
}
}Pourquoi rAF et pas un write par token
Sous zoneless, chaque signal.update() planifie une change detection. Un LLM rapide émet 80-150 tokens/s. Écrire le signal à chaque token = 100+ CD/s → jank. Le pattern append-only buffer + flush rAF plafonne à la fréquence d'écran (~60 fps) et reste fluide. C'est l'équivalent signal du auditTime(16) de RxJS — sauf qu'ici on contrôle nous-mêmes la coalescence pour ne jamais perdre un token (un auditTime dropperait des tokens intermédiaires ; nous, on les accumule).
Variante RxJS du même flux
Si vous préférez rester dans RxJS pour le transport (utile si vous chaînez retry, timeout, ou multiplexez plusieurs streams) :
// stream$ émet un événement par token / tool-call
result = toSignal(
this.agent.stream$(this.messages()).pipe(
scan((acc: string, evt) => evt.type === 'token' ? acc + evt.text : acc, ''),
// auditTime(16) pour coalescer SI on tolère de perdre des intermédiaires
catchError(() => of('')),
takeUntilDestroyed(this.destroyRef),
),
{ initialValue: '' },
);Le scan joue le rôle de buffer accumulateur. Le toSignal fait le pont vers le template. Mais pour un vrai chat token-par-token, le getReader() ci-dessus donne plus de contrôle sur le backpressure et l'AbortController.
Markdown + sécurité (XSS)
Un assistant LLM renvoie du markdown. Le rendre en HTML sans sanitization = faille XSS (un message peut contenir <img onerror=…>). Toujours passer par DomSanitizer ou un renderer qui échappe par défaut :
import { SecurityContext, computed, inject } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { marked } from 'marked';
private readonly sanitizer = inject(DomSanitizer);
// computed : re-render markdown quand le texte change, memoïzé
readonly renderedHtml = computed<SafeHtml>(() =>
this.sanitizer.sanitize(SecurityContext.HTML, marked.parse(this.assistantText()) as string) ?? '',
);Note staff : ne faites jamais
bypassSecurityTrustHtmlsur du contenu LLM. Le modèle peut être manipulé (prompt injection) pour produire du HTML hostile. Sanitize, point.
Le pont vers NestJS (côté serveur)
Le Stop côté client coupe le reader ; le serveur voit le socket se fermer. Côté NestJS, vous propagez cette annulation au SDK Anthropic via un AbortController serveur, sinon vous continuez à payer des tokens pour une réponse que personne ne lit. Modèle de référence : claude-opus-4-8 (flagship), claude-sonnet-4-6 (équilibré), claude-haiku-4-5 (rapide/économique). Le SDK gère les retries ; injectez le client via forRootAsync (DI), jamais new Anthropic() dans un field. Détail complet dans le chapitre NestJS streaming — ici, retenez que l'annulation est bout-en-bout : signal Stop → AbortController client → close socket → AbortController serveur → stream.abort() du SDK.
🏋️ Exercices
Progression : implémenter → rendre production-grade → casser puis réparer. Faites-les dans l'ordre, chacun s'appuie sur le précédent.
Exercice 1 — Le pont propre (implémenter)
Objectif : transformer un FormControl de recherche + HTTP en un trio signal d'entrée → RxJS temporel → signal de sortie, sans aucun async pipe, avec debounceTime(300) + distinctUntilChanged + cancellation des requêtes obsolètes.
Indice/Solution : query = signal('') ; result = toSignal(toObservable(query).pipe(debounceTime(300), distinctUntilChanged(), switchMap(q => api.search(q).pipe(catchError(() => of([])))) ), { initialValue: [] }). Le switchMap annule la requête précédente ; le catchError à l'intérieur du switchMap garde le flux externe vivant (un catchError à l'extérieur tuerait le stream après la 1ère erreur).
Exercice 2 — Le piège du catchError (casser puis réparer)
Objectif : reproduire le bug « après une erreur réseau, la recherche ne répond plus jamais », puis le corriger. Placez volontairement catchError au mauvais niveau.
Indice/Solution : catchError hors du switchMap complète l'observable externe → plus aucune émission ne suivra. Fix : déplacer le catchError dans le switchMap, sur l'inner observable. C'est LA question piège classique en entretien RxJS.
Exercice 3 — resource vs effect+subscribe (rendre production-grade)
Objectif : réécrire l'effect qui subscribe du CategoryPage end-to-end (plus haut) en rxResource / resource, et démontrer que l'abortSignal annule la requête en vol quand les filtres changent vite.
Indice/Solution : products = rxResource({ request: () => this.filters(), stream: ({ request }) => this.api.search(request) }). Le request lit le signal filters() ⇒ re-fetch réactif ; l'abortSignal est passé/géré par le resource ⇒ cancellation automatique. Vous supprimez l'effect qui subscribe (anti-pattern « subscribe dans effect »), le loading manuel (products.isLoading()), et le try/catch (products.error()). Test : tapez 5 valeurs en < 300 ms ⇒ une seule requête réseau survit (les autres abort).
Exercice 4 — Le glitch coûteux (casser puis réparer)
Objectif : construire deux états a et b et un dérivé ratio = a / b, en RxJS avec combineLatest, puis observer un état incohérent transitoire lors d'un double set ; reproduire le même graphe en signals et montrer l'absence de glitch.
Indice/Solution : avec combineLatest([a$, b$]), faire a$.next(10); b$.next(0) peut émettre l'intermédiaire (10, oldB) puis (10, 0) → division transitoire fausse, voire Infinity affiché un frame. En signals, a.set(10); b.set(0) est coalescé : le computed(() => a()/b()) n'est lu qu'une fois, état final cohérent, glitch-free. Mesurez avec un effect(() => log(ratio())) : RxJS logue 2 fois, signals 1 fois.
Exercice 5 — Streaming agent zoneless (production-grade, stack IA)
Objectif : implémenter le chat d'agent de la section IA ci-dessus en zoneless, avec (a) coalescing rAF des tokens, (b) bouton Stop câblé à un AbortController (client) + endpoint serveur qui abort la génération, (c) timeline de tool-calls en discriminated union, (d) markdown sanitizé.
Indice/Solution : buffer append-only pendingTokens flushé en requestAnimationFrame (1 write/frame) ; messages.update(m => m.with(i, ...)) (immutabilité → notif signal) ; status en discriminated union 'idle'|'streaming'|'error' ; stop() appelle controller.abort() et le serveur câble son propre AbortController sur le close du socket SSE pour stopper le SDK Anthropic (sinon coût tokens gaspillé). Markdown via marked + DomSanitizer.sanitize(SecurityContext.HTML, …), jamais bypassSecurityTrustHtml.
Exercice 6 — La fuite invisible (casser puis réparer)
Objectif : créer une fuite mémoire avec toObservable appelé dans le template (recréé à chaque CD), la prouver (compteur de souscriptions), puis la corriger.
Indice/Solution : appeler toObservable(x) directement dans une interpolation de template (avec le pipe async) → nouvelle souscription à chaque change detection. Instrumentez la source avec tap({ subscribe: () => count++ }) et observez count exploser. Fix : hisser readonly x$ = toObservable(this.x) en field, le template lit x$ | async (une seule souscription). Variante : oublier takeUntilDestroyed(this.destroyRef) sur un interval() souscrit dans une méthode hors injection context.
Exercice 7 — Le DOM lu trop tôt (casser puis réparer)
Objectif : afficher la hauteur en pixels d'une liste dont le contenu dépend d'un signal items(), en lisant el.offsetHeight depuis un effect(). Observer une valeur périmée (la hauteur d'avant le dernier ajout) un frame sur deux, puis corriger.
Indice/Solution : un effect() classique peut s'exécuter avant que le DOM reflète la nouvelle valeur de items() ⇒ offsetHeight lit l'ancien layout. Fix : remplacer par afterRenderEffect(() => { const h = el.offsetHeight; this.height.set(h); }) (ou afterNextRender) qui garantit l'exécution après le paint. Bonus piège : écrire height dans le même cycle peut re-trigger un render → boucle ; utilisez la phase read/write d'afterRenderEffect pour séparer mesure et écriture.
Exercice 8 — Stale-while-revalidate avec httpResource (production-grade)
Objectif : construire une page détail qui, lors d'un changement d'id, garde la donnée précédente affichée (pas de flash « Chargement… »/écran vide) pendant le re-fetch, et n'affiche un spinner que lors du tout premier chargement. Ajouter un retry réseau via interceptor.
Indice/Solution : déclarez le resource sur l'id() :
user = httpResource<User>(() => `/api/users/${this.id()}`);Dans le template, n'affichez le spinner que si user.value() === undefined && user.isLoading() (premier load), sinon laissez user.value() (l'ancien) visible pendant isLoading(). Le retry/backoff vit dans un interceptor HttpClient (provideHttpClient(withInterceptors([retryInterceptor]))), pas dans le resource — c'est ça l'avantage httpResource vs fetch brut. Vérifiez aussi le double-fetch SSR : activez provideClientHydration(withHttpTransferCacheOptions(...)).
🎤 En entretien
Q : « Pourquoi ne pas tout migrer en signals et abandonner RxJS ? » R senior : Parce que les signals n'ont pas de notion de séquence temporelle — pas de cancellation de stream, pas de backpressure (debounce/throttle), pas de combinaison multi-sources async (switchMap, mergeMap). RxJS reste l'outil pour le flux (HTTP, WS, SSE, events agrégés) ; les signals pour l'état synchrone lu au rendu. Le test : « ai-je besoin d'une séquence dans le temps ? » → oui = RxJS, non = signals. Et httpResource/resource couvrent désormais le cas le plus fréquent (fetch avec cancel) en signal-first.
Q : « Qu'est-ce que le glitch-free, et pourquoi ça compte ? » R senior : Lors de plusieurs set() synchrones, un graphe de dépendances peut exposer un état intermédiaire incohérent (un dérivé calculé avec une partie des entrées à jour et une autre périmée — le « glitch »). Les signals coalescent les writes synchrones et garantissent que les computed/effect downstream ne voient que l'état final cohérent, une seule fois. combineLatest en RxJS n'offre pas cette garantie et peut émettre l'intermédiaire. Concrètement : pas de flicker, pas de Infinity affiché un frame sur un a/b.
Q : « Comment streamer des tokens LLM dans une UI Angular zoneless sans tuer les perfs ? » R senior : On consomme le transport comme un flux (fetch + getReader() + TextDecoder, ou un Observable), mais on n'écrit pas le signal à chaque token (100+ CD/s sous zoneless = jank). On accumule dans un buffer append-only et on flush le signal une fois par frame via requestAnimationFrame (coalescing à ~60 fps, sans perdre de token — contrairement à auditTime qui en dropperait). Le bouton Stop câble un AbortController côté client et déclenche l'abort côté serveur pour ne pas payer des tokens dans le vide.
Q : « effect ou computed pour dériver un état ? Et le piège du subscribe dans un effect ? » R senior : Toujours computed pour un dérivé (pur, memoïzé, lazy, pas de risque de boucle). Les effect sont pour les side effects (DOM, localStorage, fire-and-forget). Mettre un subscribe dans un effect est un anti-pattern : on re-souscrit à chaque ré-exécution si on ne gère pas le cleanup, et on mélange flux et état. La bonne frontière flux→état, c'est toSignal/resource/rxResource, pas un subscribe manuel dans un effect. Et pour « état dérivé mais écrasable manuellement » (sélection qui suit une liste mais qu'on peut changer à la main), la primitive correcte est linkedSignal, pas un effect-qui-set.
Q : « Comment un computed sait-il quand recalculer, et pourquoi c'est plus efficace que combineLatest ? » R senior : Le graphe est construit à la lecture (dépendances dynamiques traquées pendant l'exécution), chaque nœud porte une version. Un set() ne fait que marquer dirty les dépendants (push, O(profondeur), aucun calcul) ; le recalcul n'arrive qu'au prochain read(), et seulement si la version d'une source a vraiment changé (pull + memoïzation). Résultat : un computed jamais lu ne coûte rien, et un changement qui ne modifie pas la valeur d'un computed intermédiaire stoppe la propagation (value-based dirty checking). combineLatest recalcule à chaque émission, qu'il y ait un consommateur ou non, et n'a pas de coupure par égalité — d'où plus de travail et les glitchs.
Q : « Un effect s'exécute-t-il immédiatement après set() ? Où lire le DOM ? » R senior : Non — les effects sont schedulés et tournent dans la phase de synchronisation du change detection, après le batch de set() (d'où le coalescing, et flushEffects() en test). Si je dois lire/mesurer le DOM après le rendu, j'utilise afterRenderEffect() / afterNextRender(), pas un effect() classique qui peut s'exécuter avant que le DOM reflète l'état. Pour synchroniser vers le DOM, je préfère de toute façon le binding de template (signal-aware) plutôt qu'un effect impératif.
🔗 Liens
- https://angular.dev/guide/signals
- https://angular.dev/guide/rxjs-interop
- https://angular.dev/guide/signals/resource
- https://angular.dev/api/common/http/httpResource
- https://blog.angular.io/ — articles d'équipe sur la roadmap Signals
- https://github.com/angular/angular/discussions — RFC Signal Forms
- https://angularexperts.io — articles sur la migration
- Article fondateur Sarah Drasner / Angular team — "Why signals?"
📖 Glossaire
- Signal : container réactif synchrone, pull-based.
- Computed : signal dérivé pur, memoïzé.
- Effect : side effect réactif sur signals lus.
- DestroyRef : token DI pour s'enregistrer au destroy d'un contexte (composant, service
providedIn: 'root'ne meurt jamais). - toSignal : Observable → Signal, gère l'unsubscribe automatique.
- toObservable : Signal → Observable, émet à chaque tick.
- Resource : abstraction Angular pour le fetch async signal-first.
- HttpResource : Resource + HttpClient = fetch signal-first natif.
- Coalescing : groupage des changes synchrones en un seul tick.
- Glitch-free : pas d'état intermédiaire observable.
Récap final
Le débat "Signals vs RxJS" est mal posé. En 2026, c'est Signals + RxJS, chacun à sa place. Le test mental : "est-ce que j'ai besoin d'une séquence temporelle ?" Si oui → RxJS. Si non → Signals. Le pont (toSignal/toObservable) est gratuit, sûr et idiomatique. Le seul piège récurrent reste l'effect() qui modifie un autre signal — bannissez ce pattern, et 80% de vos bugs réactifs disparaissent. Et pour le fetch HTTP, regardez httpResource d'Angular 20 : c'est probablement votre nouveau défaut.