Remplacer les indicateurs de chargement dans Angular par une union d’États asynchrones discriminés
Modélisez les opérations asynchrones d’Angular comme une union typée unique, dérivez des booléens à l’aide de signaux calculés et gérez chaque transition d’état au moyen d’un seul opérateur RxJS.
Sources: Stop Juggling `isLoading` Booleans: A Type-Safe Async State Pattern for Angular
Presque tous les composants Angular qui appellent une API finissent par suivre leur progression à l’aide de quelques booléens : un pour le chargement, un pour les erreurs, et un par bouton qui peut être en cours d’exécution. Chaque indicateur est raisonnable à part lui, mais ensemble ils permettent au composant d’atteindre des états qui n’avaient pas été conçus, et le template se transforme en une table de vérité manuscrite. Ce guide montre comment remplacer ces indicateurs par une union discriminée unique pour chaque opération asynchrone, dériver les booléens encore nécessaires à l’aide de computed, regrouper toutes les transitions dans un petit opérateur RxJS, et afficher le résultat avec @switch afin que le vérificateur de type du template impose ce qui est autorisé à être affiché dans l’interface.
La version basée sur des booléens et pourquoi elle semble convenir
Voici un écran de commandes typique qui charge une liste au démarrage et propose un bouton de paiement. Comptez les champs qui n’existent que pour suivre la progression :
@Component({ /* ... */ })
export class OrdersComponent {
isLoading = false;
isRefreshing = false;
isCheckingOut = false;
isCancelling = false;
hasError = false;
errorMessage: string | null = null;
orders: Order[] = [];
ngOnInit(): void {
this.isLoading = true;
this.api.getOrders().subscribe({
next: (orders) => {
this.orders = orders;
this.isLoading = false;
},
error: (err) => {
this.hasError = true;
this.errorMessage = err.message;
this.isLoading = false;
},
});
}
checkout(): void {
this.isCheckingOut = true;
this.hasError = false;
this.api.checkout(this.orders).subscribe({
next: () => (this.isCheckingOut = false),
error: (err) => {
this.isCheckingOut = false;
this.hasError = true;
this.errorMessage = err.message;
},
});
}
}
Le modèle correspondant doit combiner ces flags pour déterminer ce qui doit être affiché, et le bouton de paiement doit être au courant de chaque opération qui pourrait être en cours :
@if (isLoading) { <app-spinner /> }
@if (!isLoading && hasError) { <app-error [message]="errorMessage" /> }
@if (!isLoading && !hasError && orders.length === 0) { <app-empty /> }
@if (!isLoading && !hasError && orders.length > 0) { <app-order-list [orders]="orders" /> }
<button [disabled]="isLoading || isRefreshing || isCheckingOut || isCancelling">
Checkout
</button>
Rien de tout cela ne fonctionne mal, et cela passerait la plupart des audits. Le problème réside dans l’évolution : chaque nouvelle fonctionnalité ajoute un autre flag.
Pourquoi des flags indépendants ne peuvent pas décrire des états exclusifs
Cinq booléens produisent 2 à la puissance 5, soit 32 combinaisons possibles. L’écran a été conçu pour environ cinq d’entre elles : inactif, en chargement, chargé, vide et échec. Les 27 restantes sont accessibles en temps de exécution, et le système de types ne dispose d’aucun moyen de savoir qu’elles sont invalides. Ce déséquilibre se manifeste de manières familières :
- Les états contradictoires sont autorisés. Rien n’empêche que
isLoadingethasErrorsoient tous deux égaux àtrue. Ce que voit l’utilisateur dépend alors de l’ordre des blocs@if, c’est ainsi qu’un indicateur de chargement et une bannière d’erreur apparaissent ensemble à l’écran. - Le template devient une table de vérité. Une condition telle que
!isLoading && !hasError && orders.length > 0représente une ligne dans une table qui n’a jamais été écrite, et chaque nouvelle variable augmente le nombre de lignes. - Les fonctionnalités élargissent progressivement la classe. La mise à jour par tirage ajoute
isRefreshing; une annulation optimiste ajouteisCancellinget probablementcancellingId. Le comportement réel du composant se retrouve noyé sous toutes ces informations de suivi.
isLoading === false && hasError === true au lieu de « l’utilisateur voit un message d’erreur ».La cause profonde est une erreur de modélisation. Les flags sont indépendants les uns des autres, mais les états qu’ils sont censés décrire sont mutuellement exclusifs. Lorsque le type ne correspond pas au domaine, des bugs apparaissent. En changeant le type, la plupart de ces symptômes disparaissent.
Étape 1 : représenter l’état sous forme d’une seule valeur
Une union discriminée capture directement cette exclusivité. Chaque variant contient une valeur littérale status, et les données utiles n’existent que sur les variants où elles ont un sens :
import { HttpErrorResponse } from '@angular/common/http';
export type AsyncState<T, E = HttpErrorResponse> =
| { readonly status: 'idle' }
| { readonly status: 'loading' }
| { readonly status: 'success'; readonly data: T }
| { readonly status: 'error'; readonly error: E };
Un seul status peut être stocké à la fois, et le data n’est présent que lorsque le statut est 'success'. Ce que les booléens suggéraient simplement, le compilateur l’impose désormais.
De petites fonctions constructrices rendent les points d’appel plus lisibles et vous épargnent de devoir taper des littéraux de chaîne partout. Un prédicat comme isLoading est utile pour obtenir des valeurs dérivées ultérieurement :
export const idle = () => ({ status: 'idle' }) as const;
export const loading = () => ({ status: 'loading' }) as const;
export const success = <T>(data: T) => ({ status: 'success', data }) as const;
export const failure = <E>(error: E) => ({ status: 'error', error }) as const;
export const isLoading = (state: AsyncState<unknown, unknown>): boolean =>
state.status === 'loading';
Les assertions as const garantissent que chaque constructeur renvoie un type status littéral plutôt qu’une chaîne élargie, ce qui permet à ces objets d’être assignés à l’union.
Pourquoi une interface avec des champs nulables ne suffit pas
Une première tentative courante ressemble à ceci :
interface LoadState<T> {
loading: boolean;
data: T | null;
error: HttpErrorResponse | null;
}
{ loading: true, data: [...], error: {...} }, de sorte que chaque consommateur doit encore deviner quel champ est fiable. Le discriminant constitue l’élément essentiel ; sans lui, on a simplement déplacé le problème au lieu de le résoudre.
Étape 2 : un état par opération asynchrone
AsyncState, plutôt qu’un couple de booléens pour chaque cas particulier :
readonly orders = signal<AsyncState<Order[]>>(idle());
readonly checkout = signal<AsyncState<Receipt>>(idle());
Étape 3 : déduire les booléens plutôt que de les stocker
Les modèles ont encore besoin de valeurs booléennes. [disabled] en exige une, et la question « Y a-t-il quelque chose de occupé ? » est une question oui/non. Cela est tout à fait normal : les valeurs booléennes fonctionnent bien comme sorties dérivées mais moins bien comme source de vérité.
readonly isBusy = computed(() =>
[this.orders(), this.checkout()].some(isLoading),
);
readonly canCheckout = computed(
() => this.orders().status === 'success' && this.checkout().status !== 'loading',
);
Puisque ces valeurs sont calculées à partir des états de base, elles ne peuvent pas dévier. Ajouter ou supprimer une opération fait que les flags dérivés s’adaptent automatiquement ; il n’y a rien à réinitialiser ni à oublier.
Étape 4 : centraliser les transitions dans un seul opérateur RxJS
C’est en définissant manuellement isLoading = true avant une requête et false dans deux callbacks que naissent la plupart des bugs liés aux flags. Un opérateur personnalisé peut gérer toutes les transitions à la place :
import { OperatorFunction, of } from 'rxjs';
import { catchError, map, startWith } from 'rxjs/operators';
export function toAsyncState<T, E = HttpErrorResponse>(): OperatorFunction<T, AsyncState<T, E>> {
return (source$) =>
source$.pipe(
map((data) => success(data)),
catchError((error: E) => of(failure(error))),
startWith(loading()),
);
}
Le corps est composé de trois opérateurs, chacun apportant une garantie :
mapenveloppe chaque valeur émise dans un étatsuccess.
catchError convertit une erreur en une valeur failure, de sorte que le flux lui-même ne commet jamais d’erreur. Cela permet aux consommateurs tels que toSignal, rxMethod et le pipeline async de rester actifs après une demande échouée.startWith émet synchroniquement un état loading lors de l’abonnement, ce qui empêche l’interface utilisateur d’afficher un état idle avant le démarrage de la demande.Puisque plus aucun indicateur n’est disponible, il n’y a rien à oublier de réinitialiser.
Pour les données récupérées lors de la création du composant, ces éléments se combinent en une seule déclaration :
readonly orders = toSignal(
this.api.getOrders().pipe(toAsyncState()),
{ requireSync: true },
);
L’utilisation de requireSync: true est sûre ici précisément grâce à startWith. L’observable émet de manière synchrone lors de l’abonnement, ce qui fait que le signal conserve une valeur issue de la première détection de modification ; il n’est donc pas nécessaire d’inventer une initialValue. Si l’on supprime startWith, toSignal provoquera une erreur à la place.
Étape 5 : commandes déclenchées par l’utilisateur dans un signal store
Le chargement lors de l’initialisation est le cas simple. Des commandes telles que « payer », « annuler » ou « réessayer » sont déclenchées par l’utilisateur et nécessitent des règles de concurrence. Avec NgRx Signal Store, l’opérateur s’intègre directement dans rxMethod, et la même structure peut être utilisée avec un service classique si l’on ne fait pas appel à NgRx :
interface OrdersState {
orders: AsyncState<Order[]>;
checkout: AsyncState<Receipt>;
}
const initialState: OrdersState = { orders: idle(), checkout: idle() };
export const OrdersStore = signalStore(
withState(initialState),
withComputed(({ orders, checkout }) => ({
isBusy: computed(() => [orders(), checkout()].some(isLoading)),
canCheckout: computed(
() => orders().status === 'success' && checkout().status !== 'loading',
),
})),
withMethods((store, api = inject(OrdersApi)) => ({
loadOrders: rxMethod<void>(
pipe(
switchMap(() => api.getOrders().pipe(toAsyncState())),
tap((orders) => patchState(store, { orders })),
),
),
checkout: rxMethod<Order[]>(
pipe(
exhaustMap((orders) => api.checkout(orders).pipe(toAsyncState())),
tap((checkout) => patchState(store, { checkout })),
),
),
})),
withHooks({ onInit: (store) => store.loadOrders() }),
);
Quelques points méritent d’être notés. L’état du magasin contient un AsyncState par opération. withComputed définit les flags dérivés en un seul endroit. loadOrders utilise switchMap, de sorte qu’un nouveau chargement annule l’ancien. Quant à checkout, il utilise exhaustMap, qui ignore les nouveaux déclencheurs tant qu’un processus de paiement est déjà en cours.
Ce dernier choix remplace la protection nécessaire dans la version booléenne, du type if (this.isCheckingOut) return;, afin d’éviter les soumissions doubles. De telles protections ont tendance à disparaître lors des refacteurs car leur utilité n’est pas évidente. Ici, la règle de concurrence est intégrée directement dans le pipeline, juste à côté de la requête qu’elle protège.
Étape 6 : un modèle reflétant la machine à états
Avec un seul statut à passer, le modèle énumère chaque état visuel une seule fois :
@let orders = store.orders();
@switch (orders.status) {
@case ('loading') {
<app-spinner />
}
@case ('error') {
<app-error [error]="orders.error" (retry)="store.loadOrders()" />
}
@case ('success') {
@if (orders.data.length) {
<app-order-list [orders]="orders.data" />
} @else {
<app-empty />
}
}
}
<button [disabled]="!store.canCheckout()" (click)="store.checkout(orders.data)">
Checkout
</button>
Par rapport aux quatre blocs @if superposés du début, il ne reste plus de combinaisons à prendre en compte. À l’intérieur du cas 'success', orders.data est disponible et de type correct ; à l’intérieur du cas 'loading', il n’existe pas, et le vérificateur de types du template rejette toute tentative d’utilisation.
La déclaration @let effectue un travail important. TypeScript ne peut pas restreindre le type de store.orders().status entre différentes appels, car pour le compilateur chaque appel à store.orders() pourrait retourner un objet différent. En lisant cette valeur une fois dans une variable locale, on obtient une référence stable permettant de restreindre le type, exactement comme avec un const en TypeScript classique. Sans cela, il faudrait recourir à des conversions de type ou à des getters auxiliaires.
Une précaution s’applique au bouton de paiement dans cet extrait : il se trouve en dehors de @switch, ce qui signifie que orders.data n’est pas restreint à cet endroit, et le contrôle strict des types dans les templates le signalera. Il faut soit déplacer le bouton dans la branche 'success', soit fournir à la boutique une méthode de paiement qui lit directement les commandes actuelles.
Conserver les données existantes lors d’un renouvellement
Faire apparaître un écran vide chaque fois que l’utilisateur renouvelle la page est une mauvaise expérience. Avec des indicateurs, conserver les anciennes données visibles est étonnamment fragile : une condition comme isLoading && orders.length > 0 fonctionne tant que quelqu’un ne vide pas orders avant la requête. Avec une union, il s’agit simplement d’une autre variante qui conserve les données précédentes :
export type AsyncState<T, E = HttpErrorResponse> =
| { readonly status: 'idle' }
| { readonly status: 'loading' }
| { readonly status: 'reloading'; readonly data: T }
| { readonly status: 'success'; readonly data: T }
| { readonly status: 'error'; readonly error: E };
L’opérateur peut accepter l’état précédent et choisir en conséquence sa variante de départ :
export function toAsyncState<T, E = HttpErrorResponse>(
previous?: AsyncState<T, E>,
): OperatorFunction<T, AsyncState<T, E>> {
const initial =
previous?.status === 'success' ? reloading(previous.data) : loading();
return (source$) =>
source$.pipe(
map((data) => success(data)),
catchError((error: E) => of(failure(error))),
startWith(initial),
);
}
Si l’état précédent était un succès, le flux commence par reloading tout en conservant les données anciennes ; sinon, il commence par loading. Notez que ce fragment suppose l’existence d’un constructeur reloading() en plus des autres, défini de la même manière que success(). Dans le template, une branche @case ('reloading') peut afficher la liste existante accompagnée d’une fine barre de progression, réservant le tournevis plein écran pour la première charge. « Nous avons des données et nous en téléchargeons davantage » est un état simple à représenter sous forme de variante, mais qui constitue une source fréquente d’erreurs lorsqu’il s’agit de gérer deux booléens.
Et un service global de chargement ?
De nombreuses applications comptent les requêtes en cours dans un intercepteur HTTP afin de mettre à jour une barre de progression globale. C’est un usage raisonnable. Cependant, ce n’est pas une base fiable pour l’interface utilisateur des composants : un compteur ne permet pas de savoir quelle requête est en attente, les sondages en arrière-plan ainsi que le trafic d’analyse le déclenchent également, et chaque écran finit par afficher un indicateur identique.
Si vous avez besoin des deux, dérivez l’indicateur global à partir de l’état local, par exemple en utilisant une propriété computed basée sur isBusy de chaque store, et non l’inverse. L’état local ne doit jamais dépendre d’un compteur qui n’appartient pas à cet état. Pour gérer spécifiquement les échecs, notre article sur la conception pour les requêtes échouées dans Angular aborde les intercepteurs et les stratégies de tentative répétée.
Où s’insèrent resource() et httpResource()
Les API resource() et httpResource() d’Angular exposent un signal status() avec des valeurs telles que 'loading', 'reloading', 'resolved' et 'error'. Le framework a adopté la même approche : un statut distinct plutôt qu’un ensemble de flags. Ces API ont évolué au fil des dernières versions d’Angular, il convient donc de consulter la documentation actuelle pour connaître les valeurs exactes des statuts dans votre version.
Lorsque vos données proviennent d’une requête déclenchée par des entrées de signal, comme un ID, un filtre ou un numéro de page, optez d’abord pour resource(). L’union AsyncState couvre les cas où cette API ne suffit pas : des commandes telles que POST et DELETE, des bases de code encore largement basées sur RxJS, ainsi que des états spécifiques au domaine que le framework ne modélise pas. Le modèle mental reste identique dans les deux cas : un seul statut, et non plusieurs flags.
Lorsqu’un booléen reste l’outil approprié
Un écran avec une seule requête, un seul indicateur de chargement et aucune gestion d’erreur fonctionne bien avec isLoading. L’envelopper dans une union ne serait que de la formalité inutile.
Règle pratique : le deuxième booléen est le signal pour refactorer.Dès que vous envisagez d’ajouter isSaving à côté de isLoading, passez à la union. Cela ne coûte qu’un fichier de type et un opérateur.
Points clés
- Le chargement, le succès et l’erreur sont mutuellement exclusifs ; modélisez-les donc comme une union discriminée plutôt que comme des flags indépendants.
- Utilisez un
AsyncStatepar opération asynchrone, et non un flag par mode d’échec. - Considérez les booléens comme des modèles de lecture dérivés créés avec
computed, et jamais comme un état stocké.
toAsyncState() gère toutes les transitions et maintient les flux actifs en transformant les erreurs en valeurs.switchMap, exhaustMap) plutôt que des vérifications manuelles.@let avec @switch afin que le modèle suive la machine à états et que le vérificateur de types le contrôle.Le composant présent en haut de ce guide comportait sept champs de suivi ; la version refactorisée n’en a que deux, et tous deux représentent un état réel. La différence n’est pas stylistique. Un type peut décrire 32 états, tandis que l’autre ne décrit que les cinq pour lesquels l’interface a été conçue.