Skip to main content

Command Palette

Search for a command to run...

A la découverte de rxMethod

Updated
10 min readView as Markdown
A la découverte de rxMethod
E
Je suis Ezéchiel Amen AGBLA, Angular Developer Expert & Ionic Developer Expert, passionné par le développement web et mobile. Je mets la lumière sur des notions liées à Angular et à Ionic pour la communauté francophone.

Depuis l'arrivée des Signals dans Angular, une question revient sans cesse dans les équipes : comment continuer à profiter de la puissance de RxJS, de ses opérateurs, de sa gestion fine des flux asynchrones, de son switchMap par exemple qui annule les requêtes obsolètes et le tout en adoptant un modèle d'état basé sur les signaux ?

C'est exactement le problème que résout rxMethod, une fonction fournie par le package @ngrx/signals/rxjs-interop.

Elle ne remplace pas RxJS, elle le fait cohabiter proprement avec les signaux.

Le problème qu'elle résout

Avant rxMethod, connecter un flux RxJS à un signal demandait souvent du bricolage : un effect() qui souscrit manuellement à un Observable, une gestion artisanale du désabonnement, et le risque bien réel de fuites mémoire ou de traitements en double si l'on n'y prête pas attention.

Voici un exemple représentatif de ce qu'on écrivait "avant" :

import { Component, effect, inject, signal, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';

@Component({ /* ... */ })
export class TodoList implements OnDestroy {
  #todosService = inject(TodosService);
  #subscription?: Subscription;

  userId = signal(1);
  todos = signal<Todo[]>([]);

  constructor() {
    effect(() => {
      const id = this.userId(); // lecture du signal → dépendance déclarée

      // ⚠️ On souscrit manuellement à chaque exécution de l'effect
      this.#subscription = this.#todosService.getByUserId(id).subscribe({
        next: (todos) => this.todos.set(todos),
        error: (err) => console.error(err),
      });
    });
  }

  ngOnDestroy() {
    this.#subscription?.unsubscribe();
  }
}

Ce code a deux défauts typiques :

  • fuite mémoire / traitements en double : à chaque changement de userId, l'effect relance une souscription, mais n'annule jamais la précédente. Si l'utilisateur change rapidement d'identifiant, plusieurs requêtes HTTP restent actives en parallèle, et rien ne garantit que les réponses arrivent dans l'ordre. Ainsi, la plus ancienne peut très bien écraser la plus récente.

  • nettoyage fragile : on ne désabonne qu'à la destruction du composant (ngOnDestroy), pas entre deux exécutions de l'effect. Pour corriger ça "à la main", il faudrait gérer soi-même l'ancienne souscription via le onCleanup de l'effect :

effect((onCleanup) => {
  const id = this.userId();

  const sub = this.#todosService.getByUserId(id).subscribe({
    next: (todos) => this.todos.set(todos),
  });

  onCleanup(() => sub.unsubscribe());
});

rxResource, la réponse native d'Angular à ce problème

Face à ce genre de bricolage, Angular propose désormais sa propre solution native pour la lecture de données asynchrones : rxResource, dans @angular/core/rxjs-interop.

Elle résout exactement le problème illustré ci-dessus, sans effect(), sans Subscription manuelle, sans onCleanup :

import { Component, signal, inject } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';

@Component({ /* ... */ })
export class TodoList {
  #todosService = inject(TodosService);
  userId = signal(1);

  todosResource = rxResource({
    params: () => ({ userId: this.userId() }),
    stream: ({ params }) => this.#todosService.getByUserId(params.userId),
  });
}

rxResource gère lui-même l'état complet de la requête :

  • valeur : todosResource.value() ;

  • chargement : todosResource.isLoading() ;

  • erreur : todosResource.error() ;

  • l'annulation automatique de la requête précédente à chaque changement de params sans qu'on ait à écrire le moindre switchMap ni à se soucier du désabonnement.

Sur ce cas d'usage précis, une lecture pilotée par un paramètre réactif est bien évidemment plus simple qu'un rxMethod.

Alors, rxResource rend-il rxMethod inutile ?

Pas tout à fait, et c'est ce que la suite de cet article va démontrer.

Anatomie de rxMethod

import { rxMethod } from '@ngrx/signals/rxjs-interop';

function rxMethod<Input>(
  generator: (source$: Observable<Input>) => Observable<unknown>,
  config?: { injector?: Injector }
): RxMethod<Input>

rxMethod prend en paramètre un générateur qui n'est rien d'autre qu'une fonction qui reçoit un flux (Observable<Input>) et retourne un Observable transformé, typiquement via un pipe().

Le paramètre de type Input fixe le type des données attendues en entrée, indépendamment de la forme sous laquelle l'appelant les fournit.

import { Component, inject, signal } from '@angular/core';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { switchMap } from 'rxjs';
import { tapResponse } from '@ngrx/operators';

@Component({ /* ... */ })
export class TodoList {
  readonly #todosService = inject(TodosService);
  readonly userId = signal(1);
  readonly todos = signal<Todo[]>([]);

  readonly loadTodos = rxMethod<number>(
    switchMap((id) =>
      this.#todosService.getByUserId(id).pipe(
        tapResponse({
          next: (todos) => this.todos.set(todos),
          error: console.error,
        })
      )
    )
  );

  constructor() {
    this.loadTodos(this.userId);   // un Signal<number>
    this.loadTodos(this.userId$);  // un Observable<number>
    this.loadTodos(1);            // une valeur brute, number
  }
}

Trois façons d'appeler loadTodos sont possibles :

  • si c'est un Signal, il crée un effect() interne qui pousse la valeur courante du signal dans le flux source à chaque changement ;

  • si c'est un Observable, il s'y abonne directement et transmet chaque valeur émise dans le flux source ;

  • si c'est une valeur statique, il l'émet une seule fois dans le flux source, immédiatement.

La logique métier ne change pas, seule la source d'entrée varie. Dans les trois cas, la valeur finit par arriver, sous forme de number.

Pourquoi le choix de l'opérateur de flattening compte

rxMethod ne dicte aucun opérateur d'aplatissement particulier : c'est à vous de choisir entre switchMap, mergeMap, concatMap ou exhaustMap selon le comportement de concurrence souhaité.

  • switchMap : annule la requête précédente dès qu'une nouvelle arrive. C'est idéal pour une recherche ou un chargement de détail par identifiant.

  • concatMap : traite les requêtes dans l'ordre, une par une. Il est utile pour des écritures séquentielles.

  • mergeMap : lance tout en parallèle pour des opérations indépendantes.

  • exhaustMap : ignore les nouvelles requêtes tant que la précédente n'est pas terminée. C'est très pratique pour éviter les doubles soumissions.

Ce choix a un impact direct sur la fiabilité de l'application, notamment pour éviter les fameuses "race conditions" où une réponse arrivée en retard écrase un résultat plus récent.

Le contexte d'injection

rxMethod doit, par défaut, être appelée dans un contexte d'injection Angular (constructeur, champ de classe, ou tout endroit où inject() est disponible). Si ce n'est pas possible, on peut fournir explicitement un Injector :

constructor() {
  this.loadTodos = rxMethod<number>(
    switchMap((id) => /* ... */),
    { injector: this.injector }
  );
}

Cette contrainte garantit que le cycle de vie de la souscription est correctement rattaché, et donc automatiquement nettoyé à la destruction du composant.

Plus besoin de gérer manuellement un Subscription et son unsubscribe().

Les mutations : là où rxResource montre ses limites

rxResource a un modèle mental très clair : une fonction stream qui se relance automatiquement chaque fois que params() produit une nouvelle valeur.

C'est un peu comme une requête qu'on rejoue à chaque changement de paramètre. C'est excellent pour du GET.

Cependant, voyons ce qui se passe si on essaie de le détourner pour une mutation.

// ⚠️ Détournement de rxResource pour une sauvegarde - à éviter
export class TodoEditor {
  #todosService = inject(TodosService);
  todoToSave = signal<Todo | null>(null);

  saveResource = rxResource({
    params: () => this.todoToSave(),
    stream: ({ params }) => {
      if (!params) return of(undefined);
      return this.#todosService.save(params);
    },
  });

  onSubmit(todo: Todo) {
    this.todoToSave.set(todo); // 👈 on force la mutation via un changement de "requête"
  }
}

Trois problèmes concrets apparaissent immédiatement :

  • la déduplication casse le renvoi : rxResource ne relance stream que lorsque params() change de valeur. Si l'utilisateur soumet deux fois le même formulaire (même objet todo), le second clic ne déclenche rien du tout : params() n'a pas changé, donc la mutation n'est jamais rejouée. C'est un comportement correct pour une lecture, mais un bug silencieux pour une écriture.

  • l'annulation implicite est dangereuse : si todoToSave() change une nouvelle fois pendant qu'une sauvegarde précédente est encore en cours, le comportement de type switchMap de rxResource annule la requête HTTP en cours. Pour une lecture, perdre une réponse obsolète est sans conséquence. Pour une écriture, cela peut vouloir dire qu'une sauvegarde en base de données est interrompue au milieu de son exécution.

  • aucun contrôle sur la concurrence : impossible de dire à rxResource "ignore les nouveaux appels tant que le précédent n'est pas terminé" (exhaustMap) ou "empile-les dans l'ordre" (concatMap). Le modèle est figé sur un comportement proche de switchMap, pensé pour représenter un état, pas une suite d'événements.

Voyons maintenant la même mutation avec rxMethod :

export class TodoEditor {
  #todosService = inject(TodosService);
  saving = signal(false);

  saveTodo = rxMethod<Todo>(
    exhaustMap((todo) => {
      this.saving.set(true);
      return this.#todosService.save(todo).pipe(
        tapResponse({
          next: () => this.saving.set(false),
          error: (err) => {
            console.error(err);
            this.saving.set(false);
          },
        })
      );
    })
  );

  onSubmit(todo: Todo) {
    this.saveTodo(todo); // 👈 chaque appel est un événement, pas une valeur d'état
  }
}

Ici, tout fonctionne comme attendu :

  • chaque appel à saveTodo(todo) déclenche systématiquement un envoi, même avec un todo identique au précédent. rxMethod alimente en interne un flux d'événements (proche d'un Subject) : il ne compare pas les valeurs, il les pousse. Rien n'est dédupliqué.

  • exhaustMap protège explicitement contre le double clic : tant qu'une sauvegarde est en cours, les appels suivants sont ignorés. Un choix assumé, contrairement au comportement implicite et non paramétrable de rxResource.

  • aucune sauvegarde en cours n'est jamais annulée involontairement, puisque rien ne force rxMethod à réagir à un changement de "paramètre" : c'est l'appel explicite qui déclenche l'action.

La différence de fond tient en une phrase : rxResource modélise un état (une donnée dérivée d'un paramètre, qu'on relit), rxMethod modélise un flux d'événements (une action qu'on déclenche).

Vouloir faire des mutations avec le premier revient à forcer un modèle déclaratif à se comporter comme un modèle impératif. Ça fonctionne parfois en apparence, mais les cas limites (double soumission, annulation, ordre d'exécution) finissent toujours par se rappeler à vous.

Une bonne règle de partage des rôles :

Besoin Outil recommandé
Charger une donnée en fonction d'un signal (recherche, détail par id, pagination) rxResource
Déclencher une action ponctuelle (sauvegarde, suppression, envoi de formulaire) rxMethod
Chaîner des opérateurs RxJS complexes sur un flux d'événements arbitraire rxMethod

Les deux ne s'excluent pas : il est courant de voir rxResource pour l'affichage et rxMethod pour les mutations coexister dans le même composant.

Au-delà des cas de lecture

Rien n'empêche non plus d'utiliser rxMethod pour des appels HTTP simples, sans structure d'état complexe autour :

export class UserListComponent {
  #http = inject(HttpClient);
  users = signal<User[]>([]);

  loadUsers = rxMethod<void>(() =>
    this.#http.get<User[]>('/api/users').pipe(
      tap((data) => this.users.set(data))
    )
  );
}

C'est une alternative intéressante aux appels RxJS "à la main" dans un effect(), avec une API plus explicite sur ce qui déclenche réellement l'effet de bord.

En somme, rxMethod n'est pas un remplaçant de RxJS, ni un concurrent des signaux. Il joue un rôle de pont. Il permet de :

  • conserver la richesse des opérateurs RxJS (debounce, annulation, combinaison de flux) ;

  • s'intégrer nativement aux signaux Angular, sans code de liaison superflu ;

  • automatiser la gestion du cycle de vie des souscriptions ;

  • accepter indifféremment une valeur, un signal ou un observable en entrée ;

  • couvrir les mutations là où rxResource, pensé pour la lecture, montre ses limites.

Pour toute équipe qui migre progressivement vers les signaux tout en gardant des flux de données complexes basés sur RxJS, rxMethod constitue aujourd'hui l'un des outils les plus pragmatiques de l'écosystème NgRx, particulièrement complémentaire de rxResource pour tout ce qui touche à l'écriture plutôt qu'à la lecture.

Pour plus d'informations 👉🏽 rxMethod

More from this blog

A

angulardev.fr

26 posts

Je suis Ezéchiel Amen AGBLA, Angular Developer Expert & Ionic Developer Expert, passionné par le développement web et mobile.