La plupart des bugs d’interface ne sont pas des calculs faux, mais des combinaisons impossibles : un spinner et un message d’erreur en même temps, un bouton « Payer » qui marche encore pendant que le paiement est en cours, un assistant qui a sauté une étape. Ils viennent de la description d’un écran par des booléens indépendants — isLoading, hasError, isPaid. Trois booléens font huit combinaisons ; l’écran en a peut-être quatre légitimes.
Une machine à états renverse la question : listez les états qui peuvent réellement exister et, pour chacun, les événements qu’il accepte. Tout le reste devient impossible par construction, pas par discipline. Avec les signals, cela tient en une trentaine de lignes, sans bibliothèque.
Trois booléens, huit combinaisons
Voici la version habituelle. Chaque handler règle son propre flag et oublie d’en réinitialiser un autre — non par négligence, mais parce que rien ne vous oblige à penser à tous à la fois. La démo ci-dessous exécute exactement cette logique à gauche et une machine à droite.
// ✗ trois flags indépendants = 8 combinaisons ; l’écran en a 4 légitimes
loading = signal(false);
error = signal<string | null>(null);
data = signal<Order[] | null>(null);
load() {
this.loading.set(true); // oublié : error.set(null) — l’ancienne erreur est toujours à l’écran
this.api.orders().subscribe({
next: (orders) => this.data.set(orders), // oublié : loading.set(false)
error: (e) => this.error.set(e.message), // oublié : loading.set(false) — spinner ET erreur
});
}Trois booléens
loading=false · error=false · data=false
Combinaisons impossibles atteintes : 0
Une machine
state = 'idle'
Boutons désactivés = événements que cet état n’accepte pas.
Le premier pas : une union discriminée
Avant toute machine, mettez l’état dans une seule valeur dont la forme dépend de son status. data n’existe qu’en success et error qu’en error, donc « une erreur avec des données » ne peut même pas s’écrire. Dans le template, @switch affine le type : dans @case ('error'), o.error est une chaîne, pas un peut-être-undefined.
// ✓ une seule valeur ; `data` n’existe qu’en success, `error` qu’en error
type Load<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
readonly orders = signal<Load<Order[]>>({ status: 'idle' });Avec @default never; (Angular 21.2), ajouter un nouveau status au type et l’oublier dans le template devient une erreur de compilation. Plus de détails sur le nouveau control flow dans @if, @for, @switch.
Une machine sur des signals, en trente lignes
Une union dit ce que sont les états ; une machine dit aussi comment on passe de l’un à l’autre. Le cœur est une table : pour chaque état, les événements qu’il accepte et où chacun mène. send() regarde dans la table ; un événement que l’état courant n’accepte pas est simplement ignoré. can() répond à la même question pour le template, pour que les boutons ne recopient pas les règles dans [disabled]. Les guards ajoutent des conditions sur les données, et onEnter met les effets de bord à un seul endroit.
import { signal } from '@angular/core';
export type Transitions<S extends string, E extends string> = {
readonly [state in S]: Partial<Record<E, S>>;
};
export interface MachineOptions<S extends string, E extends string> {
/** conditions supplémentaires : l’événement n’est permis que si son guard renvoie true */
readonly guards?: Partial<Record<E, (from: S) => boolean>>;
/** effets de bord à l’entrée d’un état — un seul endroit pour les appels API, timers, analytics */
readonly onEnter?: Partial<Record<S, () => void>>;
}
export function createMachine<S extends string, E extends string>(
initial: S,
transitions: Transitions<S, E>,
options: MachineOptions<S, E> = {},
) {
const state = signal<S>(initial);
const target = (event: E) => transitions[state()][event];
const can = (event: E) =>
target(event) !== undefined && (options.guards?.[event]?.(state()) ?? true);
return {
state: state.asReadonly(), // personne ne peut le .set() de l’extérieur
can, // pour [disabled], au lieu de copier des conditions dans les templates
send(event: E): boolean {
if (!can(event)) return false; // non permis dans cet état : rien ne se passe
const next = target(event)!;
state.set(next);
options.onEnter?.[next]?.();
return true;
},
};
}Un checkout, avec un diagramme d’états en direct
Un parcours de paiement est le cas classique : revenir en arrière est permis depuis la livraison mais pas pendant le paiement, on ne quitte pas la livraison sans adresse, et une carte refusée a besoin d’un chemin de retour vers l’étape de paiement. Tout cela, ce sont des lignes dans une table.
Deux articles dans le panier. NEXT mène à la livraison.
- Chaque événement et la transition qu’il a provoquée apparaissent ici.
import { Service, inject, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { createMachine } from './machine';
import { PaymentsApi } from './payments-api';
type Step = 'cart' | 'shipping' | 'payment' | 'paying' | 'paid' | 'failed';
type CheckoutEvent = 'NEXT' | 'BACK' | 'PAY' | 'PAID' | 'DECLINED' | 'RETRY';
@Service()
export class CheckoutStore {
private readonly payments = inject(PaymentsApi);
readonly address = signal('');
readonly error = signal<string | null>(null);
readonly machine = createMachine<Step, CheckoutEvent>(
'cart',
{
cart: { NEXT: 'shipping' },
shipping: { NEXT: 'payment', BACK: 'cart' },
payment: { PAY: 'paying', BACK: 'shipping' },
paying: { PAID: 'paid', DECLINED: 'failed' }, // pas de BACK, pas de second PAY pendant le paiement
failed: { RETRY: 'payment' },
paid: {}, // état final
},
{
guards: {
// le guard reçoit l’état courant : aucune référence à `this.machine` dans sa propre définition
NEXT: (from) => from !== 'shipping' || this.address().trim() !== '',
},
onEnter: { paying: () => void this.pay() },
},
);
private async pay(): Promise<void> {
try {
await firstValueFrom(this.payments.charge());
this.machine.send('PAID');
} catch (e) {
this.error.set((e as Error).message);
this.machine.send('DECLINED');
}
}
}Regardez le store : le template ne décide jamais quels boutons sont permis, et aucun composant n’appelle l’API de paiement. La banque est appelée à un seul endroit — à l’entrée dans paying — donc un double clic, un bouton retour ou un composant rendu deux fois ne peuvent pas débiter la carte deux fois.
Quand utiliser une bibliothèque : XState
Une machine écrite à la main suffit pour un parcours sur un écran. Quand les états s’imbriquent (un checkout dans une application qui peut passer hors ligne), tournent en parallèle (upload et formulaire en même temps), dépendent de timers, ou que vous voulez un éditeur visuel et des tests générés, XState est la bibliothèque de référence pour les statecharts. L’idée ne change pas : états, événements, transitions. L’état de la connexion sur la page WebSockets est lui aussi une petite machine.
Quand OUI, quand NON
- Les parcours en plusieurs étapes : checkout, onboarding, assistants, validations.
- Tout ce qui est asynchrone avec un status visible : chargement, erreur, nouvel essai, annulation.
- Les contrôles à modes (lecture, édition, glisser, aperçu) et les connexions qui vont et viennent — voir WebSockets.
- Un simple interrupteur : un signal booléen est parfait.
- Des valeurs dérivées d’autres : c’est computed(), pas un état.
- Des formulaires aux champs indépendants : chaque champ est son propre état ; la bibliothèque de formulaires connaît déjà valid, dirty et pending.
Pièges fréquents
- Un booléen par morceau d’écran. Chaque nouveau flag double les combinaisons que personne n’a testées.
- Une chaîne de status sans table.
status.set('paid')depuis n’importe où, ce sont encore des booléens, dans une seule variable. - Des règles recopiées dans les templates.
[disabled]="step === 'paying' || !address"à trois endroits ; interrogez plutôtcan(). - Des effets de bord éparpillés dans les composants. Deux composants réagissent à « paying » et la carte est débitée deux fois. Utilisez
onEnter, une seule fois. - Un état sans sortie. « failed » sans RETRY ni BACK, et la seule issue est de recharger la page.
Aide-mémoire
symptôme un spinner et une erreur ensemble, un bouton qui marche quand il ne devrait pas
cause des booléens indépendants : n flags = 2^n combinaisons, la plupart impossibles
premier pas une union discriminée : { status: 'success'; data } | { status: 'error'; error }
machine un signal d’état + une table état → { ÉVÉNEMENT: état suivant }
send(event) permis seulement si la table le prévoit (et que le guard dit oui) ; sinon ignoré
can(event) pour [disabled] et pour masquer des actions, jamais de conditions dupliquées
guards des conditions sur les données (une adresse, un formulaire valide) à côté de la table
onEnter les effets de bord à UN seul endroit : entrer dans 'paying' appelle l’API
états finaux {} : rien ne peut sortir de 'paid'
chaque état a besoin d’une sortie (failed → RETRY), sinon les utilisateurs restent bloqués
valeurs dérivées toujours computed() ; la machine décide seulement ce qui PEUT arriver
imbriqué / parallèle des états dans des états, des timers, un éditeur visuel → XState