Leçon 10 · Phase 1 — Réactivité moderne RxJS · ~18 min

RxJS #3 : Subjects, async pipe, toSignal / toObservable

Tu as les opérateurs (leçon 09), mais deux questions restent ouvertes : comment fabriquer un Observable qu'on pousse soi-même (une frappe, un clic), et comment le brancher au template sans subscribe manuel (donc sans fuite). Réponses : le Subject, l'async pipe, et les deux ponts toSignal / toObservable qui relient RxJS et signals. Cette leçon boucle l'auto-save serveur laissé en suspens.

Pourquoi ça compte pour Alliance Un écran ERP mélange les deux mondes en permanence : l'état local (le poste affiché, un filtre, un compteur) veut des signals — simples, synchrones, lisibles dans le template. Les événements asynchrones (frappe débouncée, flux temps réel de mesures, réponse GraphQL) veulent RxJS et ses opérateurs. Le savoir-faire d'un lead, ce n'est pas choisir un camp : c'est faire circuler proprement une valeur de l'un à l'autre, et ne jamais laisser un abonnement fuir. Les trois briques d'aujourd'hui sont exactement ces jonctions.

1. Subject — un Observable que tu pilotes à la main

Jusqu'ici les Observables t'étaient donnés (par HttpClient, par un opérateur). Un Subject est un Observable dont tu contrôles les émissions : tu appelles .next(valeur) et tous les abonnés la reçoivent. C'est le pont entre le monde impératif (« ce clic vient d'arriver ») et le monde des flux.

import { Subject } from 'rxjs';

const saisie$ = new Subject<string>();

saisie$.subscribe(t => console.log('reçu :', t));

saisie$.next('Pos');   // → "reçu : Pos"
saisie$.next('Poste'); // → "reçu : Poste"

Un Subject ordinaire n'a pas de valeur courante : qui s'abonne trop tard rate ce qui est déjà passé. Sa variante BehaviorSubject corrige ça — elle exige une valeur initiale et rejoue toujours la dernière valeur à tout nouvel abonné. Pour un état (le filtre courant, le poste sélectionné), c'est presque toujours le BehaviorSubject qu'on veut.

import { BehaviorSubject } from 'rxjs';

const filtre$ = new BehaviorSubject<string>('tous'); // valeur initiale obligatoire
filtre$.next('actifs');
filtre$.value; // 'actifs' — on peut lire la valeur courante, comme un signal()
Analogie .NET Un Subject, c'est ton event / EventHandler C# côté RxJS : .next(x) ≈ lever l'événement, subscribe+= un handler. Et le BehaviorSubject est troublant de ressemblance avec un signal : une valeur courante lisible (.value) plus un flux de changements. La différence : le signal est fait pour le rendu synchrone dans le template ; le BehaviorSubject est fait pour être composé avec des opérateurs (debounceTime, switchMap…).

2. async pipe — afficher un flux sans subscribe

Souviens-toi : un Observable est paresseux, rien ne tourne sans subscribe. Mais s'abonner à la main dans le composant, c'est s'obliger à se désabonner à la destruction — sinon fuite mémoire. L'async pipe fait les deux pour toi, dans le template : il s'abonne, affiche la dernière valeur émise, et se désabonne tout seul quand le composant meurt.

@Component({
  selector: 'app-liste-postes',
  standalone: true,
  imports: [AsyncPipe],
  template: `
    <!-- postes$ est un Observable<Poste[]> ; async s'abonne pour toi -->
    @if (postes$ | async; as postes) {
      <ul>
        @for (p of postes; track p.id) { <li>{{ p.nom }}</li> }
      </ul>
    } @else {
      <p>Chargement…</p>
    }
  `,
})
export class ListePostesComponent {
  private api = inject(PosteApi);
  postes$ = this.api.lister(); // Observable — AUCUN subscribe ici
}

Le | async déclenche l'abonnement, et le as postes range la valeur émise dans une variable de template réutilisable. Tant que rien n'est arrivé, async renvoie null → la branche @else montre « Chargement… ». Un seul abonnement, désabonnement garanti, zéro ligne de nettoyage.

Œil de tech lead La fuite d'abonnement est le bug de revue RxJS n°1 : un flux$.subscribe(...) dans un composant, sans désabonnement, continue de tourner après la destruction — mémoire qui gonfle, callbacks fantômes. Nuance : ça vise les flux infinis (Subject, interval, valueChanges) ; un flux qui se complète, comme un http.get(), libère son abonnement tout seul (pas de fuite). Règle : dans un template, préfère toujours l'async pipe à un subscribe manuel. Si tu dois vraiment t'abonner en TypeScript, le réflexe moderne est takeUntilDestroyed() (nettoyage lié au cycle de vie). Voir un .subscribe( nu dans une PR = question automatique : « qui le désabonne ? ».

3. toSignal / toObservable — les deux ponts

Voici la jonction propre entre les deux mondes, dans @angular/core/rxjs-interop :

On boucle enfin le fil rouge : la note du champ est un signal (leçon 07), on la transforme en flux, on la débounce et on la sauve serveur avec switchMap (leçon 09), puis on ramène l'état de sauvegarde en signal pour l'afficher — sans un seul subscribe manuel.

import { Component, signal } from '@angular/core';
import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { debounceTime, map, filter, switchMap, startWith } from 'rxjs';

@Component({
  selector: 'app-note-poste',
  standalone: true,
  template: `
    <textarea [value]="texte()"
              (input)="texte.set($any($event.target).value)"></textarea>
    <small>{{ etat() }}</small>   <!-- un signal : lecture directe, sans async -->
  `,
})
export class NotePosteComponent {
  private api = inject(PosteApi);

  texte = signal('');                 // état local → signal (leçon 07)

  // pont 1 : le signal devient un flux qu'on peut composer
  private saisie$ = toObservable(this.texte);

  // pipeline de la leçon 09, enfin alimenté par une vraie source
  private sauvegarde$ = this.saisie$.pipe(
    debounceTime(400),
    map(t => t.trim()),
    filter(t => t.length > 0),
    switchMap(t => this.api.sauver(t)), // annule la sauvegarde en vol
    map(() => 'enregistré ✓'),
    startWith('—'),               // valeur affichée avant la 1re sauvegarde
  );

  // pont 2 : le flux redevient un signal, prêt pour le template
  etat = toSignal(this.sauvegarde$, { initialValue: '—' });
}

Regarde le trajet : signaltoObservable → opérateurs RxJS → toSignal → template. Le signal fait ce qu'il fait de mieux (état synchrone lisible), RxJS fait ce qu'il fait de mieux (debounce + annulation), et les deux ponts assurent la circulation. Aucun subscribe, aucun désabonnement à écrire : toSignal vit et meurt avec le composant.

Le bon sens de circulation Retiens la règle : signal pour l'état, RxJS pour l'événementiel asynchrone, et on ne fait un aller-retour que là où c'est utile. On part d'un signal (la frappe) → on passe en Observable le temps d'appliquer debounceTime/switchMap (que les signals ne savent pas faire) → on revient en signal pour l'affichage. On ne convertit pas « pour convertir » : on emprunte RxJS pour l'opérateur dont on a besoin, puis on rend la main aux signals.

4. RxJS ou signal : quand quoi ?

BesoinOutilPourquoi
État local, compteur, filtre courantsignalsynchrone, lisible direct dans le template
Valeur dérivée purecomputedmémoïsé, pas de flux nécessaire
Debounce, throttle, annulationRxJS + opérateursles signals n'ont pas la notion de temps
Flux temps réel (WebSocket, mesures)RxJSévénements multiples dans le temps
Appel HTTP / GraphQLRxJS → toSignalc'est un Observable ; on le ramène en signal pour l'afficher
Afficher un Observableasync pipe ou toSignalabonnement + désabonnement gérés pour toi
Œil de tech lead (1) Subject vs BehaviorSubject : dès qu'il y a une valeur courante à connaître (état), c'est BehaviorSubject (ou carrément un signal). Subject nu = flux d'événements purs, sans mémoire. (2) Ne fais pas cohabiter deux sources de vérité pour la même donnée (un signal et un BehaviorSubject qu'on synchronise à la main) : choisis-en une, ponte l'autre à la demande. (3) toSignal remplace l'écrasante majorité des subscribe manuels que tu écrivais en Angular 14 — si tu en vois encore beaucoup, c'est du code d'avant l'interop. (4) Un toObservable émet la valeur courante puis les changements : pense au debounceTime pour ne pas déclencher un appel sur l'état initial.

5. Vérifie ta compréhension

Un collègue remplace postes$ | async par un subscribe dans le constructeur qui stocke le tableau. Qu'est-ce que l'async pipe apportait vraiment que ce code perd ?
Une frappe est un signal. Tu veux débouncer 400 ms puis sauver serveur en annulant la sauvegarde en vol. Quelle est la faille de l'approche « tout signal, tout dans un effect » ?
On clique deux fois de suite sur le poste 42 pour le recharger. Un signal piloté par effect ne recharge qu'une fois ; un Subject recharge deux fois. Pourquoi ?
À retenir Subject = un Observable que tu pousses toi-même (.next()) ; BehaviorSubject = idem avec valeur initiale et dernière valeur rejouée (proche d'un signal). L'async pipe s'abonne et se désabonne pour toi dans le template — l'anti-fuite par défaut. Les ponts toObservable (signal → flux, pour emprunter les opérateurs) et toSignal (flux → signal, pour afficher sans subscribe) relient les deux mondes. Doctrine : signal pour l'état, RxJS pour l'asynchrone événementiel, on ne convertit que là où c'est utile.
Source primaire à lire (10 min)

Angular — RxJS Interop (toSignal / toObservable), puis l'async pipe. Côté RxJS, la fiche Subject (et sa section BehaviorSubject).

Je suis ton prof — pose-moi des questions. On vient de fermer la boucle de l'auto-save serveur (signal → toObservable → switchMap → toSignal). Tu veux qu'on remplace le faux api.sauver par un vrai HttpClient pour voir toSignal avaler une réponse HTTP, ou qu'on compare côte à côte le même écran écrit « tout signal » vs « tout Observable + async » pour sentir où chacun gagne ? Demande-moi, ici, maintenant.
◀ 09 · RxJS #2 : opérateurs (map, filter, switchMap) Suivant : 11 · Injection de dépendances Angular ▶