Skip to content

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 }) et toObservable(signal). Cleanup : takeUntilDestroyed(destroyRef). Anti-pattern absolu : un effect() qui modifie un autre signal. Pour les data fetchings : resource() (Angular 19 stable) ou httpResource() (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 httpResource qui 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 version

Deux conséquences que les juniors ratent :

  1. Lazy + memoïzé : un computed jamais lu ne se calcule jamais, même si ses sources changent 1000 fois. Le coût est payé au read(), pas au set(). C'est l'inverse de combineLatest, qui recalcule à chaque émission qu'il y ait un consommateur ou non.
  2. Equality-checking en cascade (« value-based dirty checking ») : si a change mais que computed(() => a() > 0) renvoie la même valeur (truetrue), les dépendants de ce computed ne sont pas recalculés — la propagation s'arrête net. Un changement de a peut donc ne déclencher aucun re-render. combineLatest n'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

ts
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

ts
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

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

ts
@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);
}
html
<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)

ts
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

ts
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éoccupationresource / httpResourceComment le gérer
Cancellation des requêtes obsolètesAutomatique (abortSignal re-déclenché quand le request/params change)Rien à faire — c'est le gain principal vs toSignal(http$) qui ne cancelle pas
Retry / backoffPas intégréMettre la policy dans un interceptor HttpClient (httpResource) ou retry({ delay }) dans le loader (rxResource)
État d'erreurstatus() === 'error', error() typé unknownGarder la dernière valeur valide : value() reste l'ancienne donnée pendant un reload échoué (UX « stale-while-revalidate »)
SSR / hydrationInté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'inputPas 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 initialevalue() est undefined avant la 1ʳᵉ résolutionToujours 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

ts
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

ts
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

VersionApport Signals / RxJS interop
16Signals en preview : signal(), computed(), effect(). toSignal, toObservable. takeUntilDestroyed.
17Signals stable (Nov 2023). mutate() retiré. signal({ equal }) custom equality. Inputs signal en developer preview.
17.3input(), output(), model(), viewChild(), contentChild() signal-based en preview. outputFromObservable, outputToObservable.
18Signal inputs / queries stable. Zoneless preview. effect() re-géré pour éviter les loops via les writes par défaut interdits.
19resource() API stable. linkedSignal() (writable computed). Zoneless toujours preview. Effect schedulers configurables.
20Zoneless 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

ts
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

  1. effect() qui set un autre signal — historiquement Angular exigeait allowSignalWrites: 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, utilisez computed (ou linkedSignal si writable) ; les effect sont 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 un computed mal placé.

  2. toSignal sans initialValue — le signal est T | undefined jusqu'à la 1ère émission. Soit vous gérez undefined, soit vous passez { initialValue: ... }, soit { requireSync: true } si la source émet synchroniquement.

  3. 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 par toObservable(signal).

  4. toObservable créé dans le template — appeler toObservable(x) puis | async directement 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>
  5. Mélanger BehaviorSubject et signal pour 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 via toObservable.

  6. computed avec un side effect dedanscomputed(() => { fetch(...); return ... }). Le computed peut être ré-évalué à tout moment, le fetch part en boucle. Les computed doivent être purs.

  7. Oublier que signal() compare par référencesignal({}) puis set({}) notifie (référence différente). signal(arr) puis arr.push(x); set(arr) ne notifie pas (même référence). Utilisez update(a => [...a, x]) ou signal(value, { equal: lodashIsEqual }).

  8. Lire un signal dans onPush non-signalOnPush traditionnel 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.

  9. resource() avec un request non-réactif — si request: () => ({ 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.

  10. takeUntilDestroyed() hors injection context — appel sans argument hors d'un constructor/field initializer = erreur runtime. Passez takeUntilDestroyed(this.destroyRef).

  11. Effects qui s'attendent à voir tous les changementseffect est glitch-free mais coalescé : si vous faites 3 set() 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.

  12. Croire qu'un effect est synchrone après un set() — 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 le set(). En test, c'est pour ça qu'il faut TestBed.flushEffects() (ou tick() sous fakeAsync). En prod, si vous avez besoin de lire le DOM après qu'Angular l'a peint, utilisez afterRenderEffect() / afterNextRender(), pas un effect() classique (qui peut tourner avant que le DOM reflète l'état).

  13. Lire un signal injecté hors de tout contexte réactifsignal() peut s'appeler partout, mais computed/effect/toSignal/toObservable exigent un injection context (constructor, field initializer, ou runInInjectionContext). Les appeler dans un ngOnInit ou un callback async lève NG0203. Pour effect/takeUntilDestroyed hors contexte, passez l'injector/destroyRef explicitement.

🧪 Testing — fakeAsync, TestBed.runInInjectionContext, flushEffects

Tester un signal pur

ts
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

ts
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

ts
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

ts
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 effect watch filters() et déclenche un switchMap qui pousse les résultats dans results = signal<Product[]>([]). Le HTTP reste RxJS (cancellation native par switchMap).
  • Favoris : favorites = signal<Set<string>>(new Set()) exposé par un FavoritesStore, plus un effect qui 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.

ts
// 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 });
  }
}
ts
// 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();
    }
  }
}
ts
// 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 + subscribe ci-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 de loading, de l'erreur, et fenêtre de race si deux filtres changent en < 1 RTT (le takeUntilDestroyed ne coupe pas la requête précédente, seul switchMap le ferait). La version production se réécrit en rxResource (Exercice 3) :

ts
import { 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 interne

Vous récupérez le loading, l'error, la cancellation et la déduplication gratuitement, et vous supprimez l'effect/subscribe. Gardez le pattern effect+subscribe uniquement 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 BehaviorSubject et un signal — choisissez.
  • Mettre un subscribe dans un effect — chaînez via toObservable ou utilisez resource.
  • Convertir un signal vers Observable juste pour subscribe et set un autre signal — utilisez computed ou effect directement.

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

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

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

ts
@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èreSignalObservable
Sync/AsyncSynchrone (pull)Async (push)
Valeur couranteToujours (lecture via ())Optionnel (BehaviorSubject seulement)
CancellationN/A (pas de notion de stream)First-class (unsubscribe, takeUntil...)
Combinaison multi-sourcescomputed() (simple)combineLatest, forkJoin, etc.
BackpressureCoalescing automatiqueManuel (debounce, throttle)
Glitch-freeOui (toujours cohérent)Non par défaut
Async data fetchVia resource() ou toSignal(http$)Direct (HttpClient)
Template bindinglecture directe x()pipe async sur x$
OnPush triggeringAuto (signal-aware CD)Via async pipe ou markForCheck
CleanupDestroyRef autotakeUntilDestroyed ou async pipe
TestingflushEffects, lecture directeMarble, fakeAsync, firstValueFrom
Courbe d'apprentissageFaibleÉlevée
Maturité écosystèmeRé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 avec a=10, b=20. C'est le "glitch".
  • Signals : les deux set sont 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) :

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

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

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

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

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

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

ts
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 bypassSecurityTrustHtml sur 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() :

ts
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

📖 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.

Bibliothèque tech perso — Achref