Tous les articles

De RxJS aux Signals : guide pratique pour migrer l'état dans Angular

Introduction : la révolution de la réactivité dans Angular

Depuis les premières versions d'Angular, RxJS a été le moteur de la réactivité : appels HTTP, formulaires, routeur et événements passaient tous par des Observables. Un outil très puissant, mais exigeant : pour afficher une simple valeur dérivée, il fallait des subjects, des opérateurs et des abonnements à fermer.

Avec les Signals, l'équipe d'Angular a introduit un modèle plus simple pour l'état de l'application : une valeur que l'on lit, que l'on met à jour et dont d'autres valeurs dépendent automatiquement. Le résultat : moins de code, une lecture plus immédiate et les bases d'un Angular sans Zone.js. Dans cet article, nous voyons comment et quand faire passer l'état de RxJS aux Signals, avec une refactorisation complète.

RxJS vs Signals : deux outils pour des problèmes différents

La distinction la plus utile est la suivante : RxJS décrit des événements dans le temps — clics, messages WebSocket, réponses HTTP, frappes à ralentir —, tandis que les Signals décrivent l'état actuel et les valeurs qui en découlent. Ils ne sont pas concurrents : ils résolvent des problèmes différents, comme je l'ai expliqué plus en détail dans cet article.

RxJSSignalsRemarques
BehaviorSubject<T>signal<T>()Valeur courante, lisible de façon synchrone
combineLatest() + map()computed()Dépendances suivies automatiquement
.subscribe() pour les effets de bordeffect()Uniquement vers le monde extérieur
Pipe asynclecture de valeur() dans le templateAucun abonnement à gérer
debounceTime, switchMaprestent dans RxJSPont avec toObservable

Il y a aussi un avantage pour la détection des changements : quand un template lit un signal, Angular sait exactement quels composants dépendent de cette valeur et ne met à jour que ceux-là, au lieu de revérifier tout l'arbre des composants à chaque événement.

Anatomie de la refactorisation : un cas réel

Le scénario est classique : un tableau de bord avec la liste des produits, un champ de recherche texte et le nombre d'éléments filtrés. Voici la version « old-school » avec RxJS :

@Component({ /* ... */ })
export class ProductDashboardComponent implements OnInit, OnDestroy {
  private readonly products$ = new BehaviorSubject<Product[]>([]);
  private readonly filter$ = new BehaviorSubject<string>('');
  private readonly destroy$ = new Subject<void>();

  visible: Product[] = [];
  count = 0;

  constructor(private api: ProductApi) {}

  ngOnInit(): void {
    this.api.list().pipe(takeUntil(this.destroy$))
      .subscribe(list => this.products$.next(list));

    combineLatest([this.products$, this.filter$.pipe(debounceTime(300))])
      .pipe(
        map(([list, term]) =>
          list.filter(p => p.name.toLowerCase().includes(term.toLowerCase()))),
        takeUntil(this.destroy$),
      )
      .subscribe(visible => {
        this.visible = visible;
        this.count = visible.length;
      });
  }

  onFilter(term: string): void { this.filter$.next(term); }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Cela fonctionne, mais présente les défauts typiques de cette approche :

  • Beaucoup de code répétitif : deux subjects, un subject de destruction, deux abonnements et un ngOnDestroy.
  • Un flux difficile à suivre : pour comprendre d'où vient count, il faut lire toute la chaîne d'opérateurs.
  • Un risque de fuites mémoire : il suffit d'oublier un takeUntil pour qu'un abonnement reste actif après la destruction du composant.
  • Un état dupliqué : visible et count sont des copies à synchroniser à la main.

Pas à pas : l'état avec les Signals

Étape 1 : les valeurs et les inputs deviennent des signals

Chaque BehaviorSubject qui représente un état devient un writable signal. On le met à jour avec .set() pour remplacer la valeur et avec .update() quand la nouvelle valeur dépend de la précédente :

readonly products = signal<Product[]>([]);
readonly filter = signal('');

onFilter(term: string): void {
  this.filter.set(term);                         // replace the value
}

addProduct(p: Product): void {
  this.products.update(list => [...list, p]);    // derive from the previous one
}

Étape 2 : la logique dérivée devient computed

Le combineLatest avec map devient un computed(). Les dépendances ne se déclarent pas : Angular les découvre en suivant les signals lus. De plus, un computed est paresseux et mémoïsé : il n'est recalculé que lorsqu'une dépendance change, et seulement si quelqu'un le lit.

readonly visible = computed(() => {
  const term = this.filter().toLowerCase();
  return this.products().filter(p => p.name.toLowerCase().includes(term));
});

readonly count = computed(() => this.visible().length);

// Template: no async pipe, no subscriptions
// @for (p of visible(); track p.id) { <app-product-card [product]="p" /> }
// <span>{{ count() }} products</span>

visible et count ne sont plus des copies à synchroniser : ils sont toujours cohérents par construction.

Étape 3 : les effets de bord, avec effect

effect() sert à dialoguer avec le monde extérieur : enregistrer dans le localStorage, écrire un log, synchroniser une bibliothèque tierce comme un graphique ou une carte. Il ne sert pas, en revanche, à garder deux signals alignés : dans ce cas, la réponse est presque toujours un computed.

constructor() {
  // OK: a side effect towards the outside world
  effect(() => localStorage.setItem('dashboard.filter', this.filter()));
}

// Anti-pattern: an effect that copies one signal into another
effect(() => this.total.set(this.visible().length));

// Better: derive it, and let Angular keep it in sync
readonly total = computed(() => this.visible().length);

Le pont entre les deux mondes : rxjs-interop

Les appels au backend — une API REST ou NestJS — restent naturellement des Observables : HttpClient renvoie des flux, et c'est là que l'annulation, les nouvelles tentatives et les opérateurs temporels sont utiles. Le paquet @angular/core/rxjs-interop offre deux ponts :

  • toSignal() : transforme un flux, par exemple une réponse HTTP, en un signal utilisable directement dans le template.
  • toObservable() : transforme un signal en flux lorsqu'on a besoin d'opérateurs temporels comme debounceTime.

Voici une recherche en temps réel qui utilise les deux : le champ de recherche est un signal, le debounce et l'annulation des requêtes restent dans RxJS, et le résultat revient au template sous forme de signal. Les deux fonctions doivent être appelées dans un contexte d'injection, par exemple comme initialiseurs de champ.

private readonly api = inject(ProductApi);

readonly query = signal('');

// signal → Observable (time operators) → signal (template)
readonly results = toSignal(
  toObservable(this.query).pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap(q => this.api.search(q)),
  ),
  { initialValue: [] as Product[] },
);

// Template: <input (input)="query.set($any($event.target).value)" />
//           @for (p of results(); track p.id) { ... }

Pattern architectural : un store basé sur les Signals

Quand l'état est nécessaire à plusieurs composants, la logique passe dans un service. La règle est d'encapsuler : le signal modifiable reste privé, on expose à l'extérieur une version en lecture seule avec .asReadonly(), et les modifications passent uniquement par les méthodes du service.

@Injectable({ providedIn: 'root' })
export class CartStore {
  private readonly _items = signal<CartItem[]>([]);

  readonly items = this._items.asReadonly();   // read-only from outside
  readonly total = computed(() =>
    this._items().reduce((sum, i) => sum + i.price * i.qty, 0));

  add(item: CartItem): void { this._items.update(list => [...list, item]); }
  clear(): void { this._items.set([]); }
}

Pour les applications plus importantes, le Signal Store de @ngrx/signals porte le même modèle au niveau global, avec une structure standard pour l'état, les valeurs dérivées et les méthodes.

Conclusions et checklist

  1. Utilisez les Signals pour l'état synchrone et l'UI : valeurs courantes, filtres, sélections, compteurs.
  2. Utilisez RxJS pour les événements asynchrones et les flux dans le temps : HTTP, WebSocket, debounce, annulation. Les opérateurs RxJS restent précieux.
  3. Utilisez computed pour tout ce qui est dérivé, jamais un effect qui copie des valeurs.
  4. Faites coopérer les deux mondes avec rxjs-interop, plutôt que de tout réécrire.
  5. Migrez un composant à la fois, en commençant par ceux qui ont le plus d'abonnements manuels.

Avez-vous déjà migré une partie de votre application vers les Signals, ou hésitez-vous sur la frontière à tracer avec RxJS ? Laissez un commentaire ci-dessous pour partager votre expérience : les questions les plus intéressantes deviendront des idées pour les prochains articles.

💬 Notes des lecteurs

0 notes

Écrire une note

Partagez votre avis, une suggestion ou un compliment

Dernières notes

Aucune note pour le moment. Soyez le premier à commenter !