~7 min de lecture
abortSignal avec resource() : le pattern anti race condition que tout le monde oublie
Tu as une liste d'utilisateurs à gauche, un panneau de détail à droite. Tu cliques sur Alice. Puis vite sur Bob. Puis sur Charlie. Le panneau affiche... Bob. Pas Charlie.
Ce n'est pas un bug de rendu. Ce n'est pas non plus une régression change detection. C'est une race condition classique : les trois requêtes HTTP sont parties, la réponse de Bob est arrivée après celle de Charlie, et ton effect() a écrit "Bob" dans le signal après "Charlie". Le dernier click perd contre la dernière réponse.
resource() est censé tuer ce bug. Il gère le lifecycle, expose un abortSignal, et se recharge automatiquement quand ses params changent. Sur le papier, tu n'as plus rien à orchestrer.
En pratique, quatre pièges suffisent à te ramener exactement au même bug qu'avant. Voici les repérer.
Introduit en développeur preview dès Angular 19,
resource()(commerxResource()ethttpResource()) est resté expérimental jusqu'en Angular 21 inclus, et n'est devenu stable qu'en Angular 22 (juin 2026). Si ton projet est encore en Angular 19-21, l'API fonctionne comme décrit ici mais reste marquée expérimentale : vérifie les warnings de compilation et le changelog avant de t'appuyer dessus en prod.
Le bug de base : effect() + fetch, sans garde-fou
Voilà le code que 80% des devs écrivent quand on leur demande "un panneau qui montre le détail de l'utilisateur sélectionné". Il marche pour la démo, il casse en prod dès que le réseau devient lent ou que l'utilisateur clique vite.
import { Component, ChangeDetectionStrategy, signal, effect, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
interface User {
id: number;
name: string;
bio: string;
}
@Component({
selector: 'app-user-detail',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (user(); as u) {
<h2>{{ u.name }}</h2>
<p>{{ u.bio }}</p>
}
`,
})
export class UserDetail {
private readonly http = inject(HttpClient);
readonly userId = signal<number | null>(null);
readonly user = signal<User | null>(null);
constructor() {
effect(() => {
const id = this.userId();
if (id === null) return;
this.http.get<User>(`/api/users/${id}`).subscribe(u => {
this.user.set(u);
});
});
}
}
Le problème n'est pas visible tant que tu testes seul avec un réseau rapide. Mais tu cliques Alice (id=1), puis Bob (id=2), puis Charlie (id=3). Trois requêtes partent. Si la réponse pour Bob arrive après celle de Charlie (backend un peu chargé, cache miss côté serveur, whatever), la dernière écriture dans user sera Bob. Ton composant affiche Bob alors que userId() vaut 3.
Le pire, c'est que même les tests unitaires ne l'attrapent pas, sauf si tu simules explicitement des délais inversés. En review, le code a l'air raisonnable. Il n'est pas raisonnable.
Ce que resource() change
resource() est la primitive prévue exactement pour ce cas. Tu lui donnes des params réactifs et un loader, et il garantit trois choses :
- Le loader se relance à chaque changement de
params, sans que tu écrives d'effect(). - La réponse précédente est ignorée si les
paramschangent avant que le fetch précédent termine. - Un
abortSignalest fourni au loader, qui te permet d'annuler physiquement la requête en cours (économie CPU, économie bande passante, économie facture backend).
Réécrit correctement, notre composant devient :
import { Component, signal, resource, ChangeDetectionStrategy } from '@angular/core';
interface User {
id: number;
name: string;
bio: string;
}
@Component({
selector: 'app-user-detail',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (userResource.isLoading()) {
<app-spinner />
} @else if (userResource.error()) {
<p class="error">Erreur de chargement</p>
} @else if (userResource.hasValue()) {
<h2>{{ userResource.value().name }}</h2>
<p>{{ userResource.value().bio }}</p>
}
`,
})
export class UserDetail {
readonly userId = signal<number | null>(null);
readonly userResource = resource({
params: () => this.userId(),
loader: async ({ params: id, abortSignal }) => {
if (id === null) return null;
const res = await fetch(`/api/users/${id}`, { signal: abortSignal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<User>;
},
});
}
Trois choses ont disparu : le signal<User> manuel, l'effect(), le subscribe. Trois choses sont apparues : params, loader, abortSignal. Le composant ne peut plus afficher le mauvais utilisateur, parce que resource() internalisait la logique "j'ignore la réponse du fetch précédent". Et abortSignal s'occupe du reste : la requête pour Bob est physiquement annulée dès que Charlie est demandé.
C'est le pattern que tu veux 90% du temps. Sauf que...
Piège 1 : oublier abortSignal dans l'appel fetch
C'est le piège numéro un. Tu passes de HttpClient à fetch, tu récupères abortSignal dans les paramètres du loader... et tu ne le passes pas à fetch.
readonly userResource = resource({
params: () => this.userId(),
loader: async ({ params: id }) => {
// 🚨 abortSignal non déstructuré, non passé à fetch
const res = await fetch(`/api/users/${id}`);
return res.json() as Promise<User>;
},
});
Ça marche visuellement. Tu ne verras pas de mauvais utilisateur affiché, parce que resource() ignore quand même la réponse du fetch précédent (c'est son job). Mais le fetch continue jusqu'au bout côté serveur. Tu payes la requête backend, tu payes la bande passante, et si ton API a des side effects logués (analytics, rate limiting), tu les déclenches pour rien.
En dev tools, tu le vois : sur trois clicks rapides, tu as trois requêtes qui vont jusqu'à 200 OK. Sur un endpoint lent, ça devient dramatique.
La bonne version passe abortSignal au fetch. Toujours. Pas de "je verrai plus tard".
loader: async ({ params: id, abortSignal }) => {
const res = await fetch(`/api/users/${id}`, { signal: abortSignal });
return res.json() as Promise<User>;
},
Si tu utilises HttpClient (parce que tu tiens à tes interceptors), la même logique s'applique avec la conversion via fromEvent sur abortSignal.aborted ou plus simplement en utilisant httpResource() qui câble tout ça pour toi.
Piège 2 : params qui retourne un objet non stable
Deuxième piège, plus vicieux. Tes params retournent un objet composé, pas une valeur primitive.
readonly userResource = resource({
// 🚨 objet littéral recréé à chaque lecture des signals amont
params: () => ({ id: this.userId(), locale: this.locale() }),
loader: async ({ params, abortSignal }) => {
const res = await fetch(
`/api/users/${params.id}?locale=${params.locale}`,
{ signal: abortSignal },
);
return res.json() as Promise<User>;
},
});
Le problème : resource() compare les params par identité de référence (avec un Object.is par défaut). Deux objets littéraux avec les mêmes clés et valeurs ne sont pas égaux. Donc chaque re-run du computed interne (lecture des signals amont) génère de nouveaux params, ce qui relance le loader. Boucle, au mieux perf dégradée. Au pire, boucle infinie si un signal amont est lui-même écrit par un effect en aval.
Deux solutions. Soit tu passes equal explicite à resource() (comparaison structurelle) :
readonly userResource = resource({
params: () => ({ id: this.userId(), locale: this.locale() }),
equal: (a, b) => a.id === b.id && a.locale === b.locale,
loader: async ({ params, abortSignal }) => { /* ... */ },
});
Soit, mieux, tu joues avec des primitives dès que possible. Un objet dans params c'est le signe que tu combines plusieurs signaux, et à ce moment-là un computed intermédiaire clarifie l'intention :
readonly userQuery = computed(() => `${this.userId()}|${this.locale()}`);
readonly userResource = resource({
params: () => this.userQuery(), // string primitive, comparaison triviale
loader: async ({ params, abortSignal }) => {
const [id, locale] = params.split('|');
// ...
},
});
Chaque approche a son coût. L'objet littéral avec equal reste plus lisible pour du typé fort. La concaténation est plus rapide à écrire mais parse-fragile. Choisis, mais ne laisse jamais l'objet littéral nu.
Piège 3 : confondre isLoading(), hasValue(), error() dans le template
Troisième piège, purement template. resource() expose trois signals d'état que tout le monde utilise mal la première fois :
isLoading(): le loader est en cours d'exécution (initial ou reload).hasValue():value()contient quelque chose de non-undefined.error(): le loader a throw.status():'idle' | 'loading' | 'error' | 'resolved' | 'reloading' | 'local'.
Le piège classique, c'est de croire que "loading OU value OU error" couvre tous les cas. Ça n'en couvre pas un critique : le reload. Quand params change et que le loader se relance, hasValue() reste true (la valeur précédente est encore là), et isLoading() passe à true. Si tu écris :
@if (userResource.isLoading()) {
<app-spinner />
} @else if (userResource.hasValue()) {
<h2>{{ userResource.value().name }}</h2>
}
Tu masques complètement l'ancien contenu pendant le reload. Résultat : l'utilisateur voit son panneau clignoter à chaque changement de sélection. UX cassée.
Le pattern qui marche : traiter le premier loading (sans valeur précédente) et le reloading (avec valeur précédente) différemment.
@if (userResource.hasValue()) {
<div [class.reloading]="userResource.isLoading()">
<h2>{{ userResource.value().name }}</h2>
<p>{{ userResource.value().bio }}</p>
</div>
} @else if (userResource.isLoading()) {
<app-spinner />
} @else if (userResource.error()) {
<p class="error">Erreur de chargement</p>
}
Tu affiches l'ancienne valeur (légèrement grisée via .reloading) pendant le fetch de la nouvelle. Le spinner ne sort qu'au tout premier chargement. Zero clignotement, zero perte de contexte visuel. C'est le pattern "stale-while-revalidate" gratuit.
Piège 4 : set() optimiste qui perd au prochain reload
Dernier piège, celui qui te fait relire la doc trois fois. resource() expose une méthode set() pour écrire la valeur locale directement (utile pour un update optimiste). Mais si tes params se réévaluent (même pour la même valeur logique), la valeur locale est perdue et remplacée par le résultat du loader.
// Update optimiste : on met à jour la valeur avant que le PATCH revienne
async saveBio(bio: string): Promise<void> {
const current = this.userResource.value();
if (!current) return;
this.userResource.set({ ...current, bio }); // ✅ affiché immédiatement
await this.http.patch(`/api/users/${current.id}`, { bio }).toPromise();
// 🚨 si un signal amont retrigger params ici, la valeur locale saute
}
La règle : set() est pour l'affichage local (loading optimiste), pas pour se substituer au reload. Si tu veux vraiment que la valeur reste persistante, tu dois soit forcer un reload() explicite après le PATCH (avec la vraie valeur serveur), soit gérer l'état de mutation séparément.
Le pattern robuste ressemble à ça :
readonly bioMutation = signal<{ pending: string } | null>(null);
readonly displayedUser = computed(() => {
const base = this.userResource.value();
const mutation = this.bioMutation();
if (!base) return null;
return mutation ? { ...base, bio: mutation.pending } : base;
});
async saveBio(bio: string): Promise<void> {
this.bioMutation.set({ pending: bio });
try {
await this.http.patch(`/api/users/${this.userId()!}`, { bio }).toPromise();
this.userResource.reload();
} finally {
this.bioMutation.set(null);
}
}
C'est plus verbeux, mais la mutation optimiste et la source de vérité serveur restent découplées. set() reste pertinent pour du cache local pur, pas pour orchestrer une mutation.
Quand tu dois quand même rester en RxJS
resource() couvre le cas "un fetch qui dépend de params réactifs, et je veux le dernier résultat". Ça fait 80% des besoins UI. Les 20% restants relèvent encore de RxJS :
- Streams continus : WebSocket, SSE, EventSource. Tu veux consommer chaque message, pas garder le dernier.
resource()n'est pas conçu pour ça. - Retry chainé avec backoff :
retryWhen,expand, orchestration temporelle fine.resource()peut retry viareload(), mais pas avec une politique de backoff sophistiquée out-of-the-box. - Combinaison de plusieurs streams :
combineLatest,merge,switchMapavec transformations intermédiaires. Uncomputed()peut couvrir la combinaison de signaux, mais pas d'un signal + d'un stream continu.
Dans ces cas, tu restes en RxJS et tu convertis avec toSignal() à la sortie. Pas de honte.
Récap actionnable
Cinq points à ajouter à ta checklist de review dès qu'un composant fait du fetch réactif :
- Le composant utilise
resource()(ouhttpResource()) plutôt queeffect()+subscribe. abortSignalest déstructuré ET passé aufetch/ à la requête. Sans exception.paramsretourne une primitive, ou fournit unequalexplicite pour les objets.- Le template gère
hasValue() && isLoading()(reload) comme un cas distinct pour éviter le clignotement. set()n'est jamais utilisé pour "persister" une valeur au-delà du prochain reload. Passe par une mutation locale +reload().
Le race condition dans les composants Angular n'est pas un bug d'Angular. C'est un bug de discipline. resource() te donne les outils. À toi de ne pas les court-circuiter.