A la découverte de rxMethod

Search for a command to run...

No comments yet. Be the first to comment.
Si vous suivez l'actualité d'Angular avec la sortie de la version 20 du framework, vous avez probablement entendu de plus en plus ce terme. Mais au-delà du jargon, qu'est-ce que le Zoneless concrètement pour vos applications Angular ? Pourquoi est-il...

Si vous avez l’habitude de manipuler des formulaires réactifs (Reactive Forms) dans un projet Angular, vous connaissez certainement la méthode patchValue(…). Cette méthode, permet de mettre à jour les valeurs de votre formulaire. Cependant, son utili...

Depuis la sortie de la version 17 du framework Angular, on parle de la renaissance de Angular. Il y a eu beaucoup de changements majeurs depuis la version 16 du framework pour préparer le terrain à cette renaissance. Ces changements majeurs ont rendu...

Dans notre précédent article, nous avons vu ensemble ce qu'est un Signal Input et comment l'utiliser. Nous avons particulièrement remarqué sa similarité avec le décorateur @Input(). Dans ce nouvel article, nous verrons ensemble un nouveau type de inp...

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.
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());
});
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,
rxResourcerend-ilrxMethodinutile ?Pas tout à fait, et c'est ce que la suite de cet article va démontrer.
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.
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.
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().
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 :
rxResourcemodélise un état (une donnée dérivée d'un paramètre, qu'on relit),rxMethodmodé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.
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