Tout développeur React a déjà écrit ce code :
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isRetrying, setIsRetrying] = useState(false)Quatre booléens, cela fait seize combinaisons possibles, et seulement quatre d'entre elles sont légales. Que se passe-t-il si isLoading et isSuccess valent true en même temps ? Rien de bon — mais rien dans votre code ne l'empêche non plus. C'est exactement la catégorie de bugs qui produit des clients débités deux fois, des spinners bloqués et des formulaires soumis en double.
XState v5 résout ce problème en rendant les états illégaux impossibles à représenter. Au lieu d'un sac de booléens, vous déclarez un ensemble fini d'états et les transitions exactes entre eux. Si une transition n'est pas déclarée, elle ne peut pas se produire. La machine ignore simplement l'événement.
Dans ce tutoriel, vous allez construire une fonctionnalité réaliste — un tunnel de paiement multi-étapes avec appel asynchrone, tentatives automatiques, annulation et téléversements parallèles — avec XState v5, React et TypeScript dans une application Next.js. À la fin, vous maîtriserez les acteurs, l'API setup(), les invocations de promesses, les gardes, le spawning, la persistance et les tests.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Node.js 20+ installé
- Une bonne connaissance des hooks React et des génériques TypeScript
- Une familiarité avec
async/awaitet les promesses - Un éditeur de code (VS Code avec l'extension Stately est idéal)
- Environ 60 minutes de concentration
Aucune expérience préalable d'XState n'est nécessaire. Si vous avez utilisé XState v4, sachez que la v5 est une refonte majeure : les anciennes API Machine(), withContext() et services ont disparu.
Ce que vous allez construire
Une machine de paiement qui :
- Collecte les détails du panier (état inactif)
- Valide le panier avant d'autoriser la soumission (gardes)
- Appelle une API de paiement (acteur promesse)
- Réessaie jusqu'à trois fois en cas d'échec réseau avec un délai exponentiel (transitions différées)
- Peut être annulée en plein vol par l'utilisateur (arrêt de l'acteur)
- Téléverse les pièces jointes en parallèle (acteurs enfants)
- Survit à un rechargement de page (persistance)
Au passage, vous verrez pourquoi c'est la machine — et non le composant — qui devient la source de vérité.
Étape 1 : mise en place du projet
Créez une application Next.js et ajoutez les paquets XState :
npx create-next-app@latest xstate-checkout --typescript --app --tailwind
cd xstate-checkout
npm install xstate @xstate/react
npm install -D @statelyai/inspectTrois paquets, trois rôles :
xstate— le cœur agnostique du framework (machines, acteurs, interpréteurs)@xstate/react— les liaisons React (useMachine,useSelector,createActorContext)@statelyai/inspect— un débogueur visuel en direct, réservé au développement
Vérifiez que vous êtes bien en v5 (n'importe quelle version 5.x) :
npm ls xstateÉtape 2 : votre première machine avec setup()
Le plus grand changement de la v5 est la fonction setup(). C'est là que vous enregistrez vos types, acteurs, actions, gardes et délais avant de définir la machine. Tout ce qui est enregistré dans setup() devient entièrement typé à l'intérieur de la définition — plus d'étape typegen, plus de fichiers générés.
Créez src/machines/checkout.machine.ts :
import { setup, assign } from 'xstate'
// Les données que la machine transporte d'un état à l'autre
interface CheckoutContext {
cartTotal: number
email: string
orderId: string | null
errorMessage: string | null
retries: number
}
// Tous les événements que la machine peut recevoir
type CheckoutEvent =
| { type: 'CART.UPDATE'; total: number }
| { type: 'EMAIL.CHANGE'; email: string }
| { type: 'SUBMIT' }
| { type: 'CANCEL' }
| { type: 'RETRY' }
| { type: 'RESET' }
export const checkoutMachine = setup({
types: {
context: {} as CheckoutContext,
events: {} as CheckoutEvent,
},
}).createMachine({
id: 'checkout',
initial: 'editing',
context: {
cartTotal: 0,
email: '',
orderId: null,
errorMessage: null,
retries: 0,
},
states: {
editing: {
on: {
'CART.UPDATE': {
actions: assign({ cartTotal: ({ event }) => event.total }),
},
'EMAIL.CHANGE': {
actions: assign({ email: ({ event }) => event.email }),
},
SUBMIT: { target: 'submitting' },
},
},
submitting: {},
success: { type: 'final' },
failure: {},
},
})Observez la forme d'une action : ({ context, event }) => valeur. En v5, chaque callback reçoit un unique objet en argument, et non des arguments positionnels. C'est précisément ce changement qui rend toute l'API inférable.
Remarquez aussi ce qui n'est pas là : pas de isLoading, pas de isError. Le nom de l'état est l'indicateur de chargement. Une machine ne peut pas être dans editing et submitting en même temps — le système de types et le runtime l'interdisent tous les deux.
Étape 3 : brancher la machine dans React
Le hook useMachine démarre un acteur au montage du composant et l'arrête au démontage. Créez src/components/Checkout.tsx :
'use client'
import { useMachine } from '@xstate/react'
import { checkoutMachine } from '@/machines/checkout.machine'
export function Checkout() {
const [snapshot, send] = useMachine(checkoutMachine)
return (
<form
onSubmit={(e) => {
e.preventDefault()
send({ type: 'SUBMIT' })
}}
>
<input
type="email"
value={snapshot.context.email}
onChange={(e) => send({ type: 'EMAIL.CHANGE', email: e.target.value })}
/>
<button type="submit" disabled={!snapshot.can({ type: 'SUBMIT' })}>
{snapshot.matches('submitting') ? 'Traitement…' : 'Payer'}
</button>
{snapshot.matches('failure') && <p role="alert">{snapshot.context.errorMessage}</p>}
</form>
)
}Deux méthodes font l'essentiel du travail ici.
snapshot.matches('submitting') pose une question sur l'état courant. Comme les noms d'états proviennent de setup(), TypeScript les complète automatiquement et rejette les fautes de frappe à la compilation.
snapshot.can(...) est celle qui élimine toute une famille de bugs. Elle demande à la machine : si j'envoyais cet événement maintenant, est-ce que quelque chose se passerait ? La machine sait déjà que SUBMIT n'est traité que dans editing, donc le bouton se désactive tout seul dans tous les autres états. Vous n'écrirez plus jamais disabled={isLoading || isSubmitting || !isValid} — et vous ne pourrez plus jamais oublier une de ces conditions.
Étape 4 : l'asynchrone avec les acteurs promesses
Dans XState v5, tout est acteur : machines, promesses, callbacks, observables. Pour appeler votre API de paiement, enveloppez-la avec fromPromise et enregistrez-la dans setup().
import { setup, assign, fromPromise } from 'xstate'
interface PaymentInput {
email: string
amount: number
}
interface PaymentResult {
orderId: string
}
async function chargeCard(input: PaymentInput): Promise<PaymentResult> {
const res = await fetch('/api/payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
})
if (!res.ok) {
throw new Error(`Échec du paiement, statut ${res.status}`)
}
return res.json()
}
export const checkoutMachine = setup({
types: {
context: {} as CheckoutContext,
events: {} as CheckoutEvent,
},
actors: {
// fromPromise transforme n'importe quelle fonction async en acteur
chargeCard: fromPromise<PaymentResult, PaymentInput>(({ input }) => chargeCard(input)),
},
}).createMachine({
// ...
})Les deux paramètres génériques sont le type de sortie puis le type d'entrée, dans cet ordre. Invoquez maintenant l'acteur depuis l'état submitting :
submitting: {
invoke: {
src: 'chargeCard',
// input projette le contexte de la machine sur l'entrée de l'acteur
input: ({ context }) => ({
email: context.email,
amount: context.cartTotal,
}),
onDone: {
target: 'success',
actions: assign({
orderId: ({ event }) => event.output.orderId,
}),
},
onError: {
target: 'failure',
actions: assign({
errorMessage: ({ event }) => (event.error as Error).message,
}),
},
},
on: {
CANCEL: { target: 'editing' },
},
},Lisez ce bloc attentivement, car c'est le cœur d'XState. invoke signifie : tant que cet état est actif, exécute cet acteur. La fonction input est le seul pont entre le contexte de la machine et l'acteur — l'acteur ne peut pas aller lire votre contexte, ce qui le rend testable indépendamment.
En cas de succès, la valeur de la promesse arrive dans event.output. En cas d'échec, l'erreur arrive dans event.error. TypeScript connaît les deux types grâce à vos génériques fromPromise.
Et le gestionnaire CANCEL ? Lorsque la machine quitte submitting, XState arrête automatiquement l'acteur invoqué. Aucun nettoyage useEffect à écrire, aucun AbortController à ne pas oublier, aucun avertissement « composant démonté ». Quitter un état détruit tout ce que cet état possédait. C'est cette propriété qui rend les machines nettement plus sûres que des hooks improvisés : le nettoyage est structurel, il ne repose pas sur votre mémoire.
Étape 5 : les gardes, ou refuser les transitions invalides
Pour l'instant, un utilisateur peut soumettre un panier vide. Les gardes sont des prédicats purs qui décident si une transition est autorisée. Enregistrez-les dans setup() :
export const checkoutMachine = setup({
types: { /* ... */ },
actors: { /* ... */ },
guards: {
isCartValid: ({ context }) =>
context.cartTotal > 0 && context.email.includes('@'),
canRetry: ({ context }) => context.retries < 3,
},
}).createMachine({
// ...
})Puis attachez la garde à la transition :
editing: {
on: {
SUBMIT: {
target: 'submitting',
guard: 'isCartValid',
},
},
},Le bénéfice est immédiat : snapshot.can({ type: 'SUBMIT' }) évalue désormais aussi la garde. Votre bouton Payer se grise automatiquement pour un panier invalide, avec zéro ligne de code supplémentaire dans le composant. La logique de validation vit à un seul endroit, et l'interface la lit au lieu de la dupliquer.
Étape 6 : réessais avec délai exponentiel
Les appels réseau échouent. Un tunnel de paiement robuste réessaie automatiquement les échecs transitoires, mais borne le nombre de tentatives. Modélisez cela avec un état retrying et une transition différée (after).
Enregistrez un délai dynamique dans setup() :
delays: {
// 1s, 2s, 4s — délai exponentiel basé sur le nombre de tentatives
backoff: ({ context }) => 2 ** context.retries * 1000,
},Puis restructurez le chemin d'échec :
failure: {
// Réessaie automatiquement s'il reste des tentatives
always: {
target: 'retrying',
guard: 'canRetry',
},
on: {
RESET: { target: 'editing', actions: 'clearError' },
},
},
retrying: {
entry: assign({ retries: ({ context }) => context.retries + 1 }),
after: {
// Attend le délai calculé, puis réessaie
backoff: { target: 'submitting' },
},
on: {
CANCEL: { target: 'editing' },
},
},Trois idées travaillent réellement ici.
Une transition always (dite « sans événement ») se déclenche dès l'entrée dans l'état, si sa garde passe. Ainsi failure bascule immédiatement vers retrying tant qu'il reste des tentatives, et reste sur place — en attendant un RESET humain — dès que canRetry renvoie faux.
Le bloc after programme une transition sur minuterie. Comme backoff est une fonction de délai enregistrée, elle est recalculée à chaque entrée : 1 seconde, puis 2, puis 4.
Et la minuterie appartient à l'état. Si l'utilisateur clique sur CANCEL pendant l'attente, la machine quitte retrying et la minuterie en attente est annulée avec lui. Aucun setTimeout fantôme ne se déclenchera deux secondes plus tard contre un composant démonté. Comparez avec ce que donnerait cette logique en useEffect avec une ref contenant un identifiant de timeout.
Étape 7 : acteurs enfants pour les téléversements parallèles
Les acteurs invoqués vivent et meurent avec un état. Mais il arrive qu'on ait besoin d'un nombre dynamique d'acteurs durables — un par fichier téléversé, chacun avec sa progression indépendante. C'est le rôle du spawning.
D'abord une petite machine de téléversement. Chaque fichier obtient sa propre instance :
import { setup, fromPromise, assign } from 'xstate'
export const uploadMachine = setup({
types: {
context: {} as { file: File; progress: number; url: string | null },
input: {} as { file: File },
},
actors: {
uploadFile: fromPromise<string, { file: File }>(async ({ input }) => {
const body = new FormData()
body.append('file', input.file)
const res = await fetch('/api/upload', { method: 'POST', body })
const data = await res.json()
return data.url as string
}),
},
}).createMachine({
id: 'upload',
// input alimente le contexte initial — voilà comment la v5 remplace withContext()
context: ({ input }) => ({ file: input.file, progress: 0, url: null }),
initial: 'uploading',
states: {
uploading: {
invoke: {
src: 'uploadFile',
input: ({ context }) => ({ file: context.file }),
onDone: {
target: 'done',
actions: assign({ url: ({ event }) => event.output }),
},
onError: { target: 'failed' },
},
},
done: { type: 'final' },
failed: {
on: { RETRY: { target: 'uploading' } },
},
},
})Puis engendrez-en une par fichier depuis la machine parente, avec enqueueActions :
import { enqueueActions } from 'xstate'
// Dans le setup() de la machine parente :
actors: {
chargeCard: /* ... */,
uploadMachine,
},
// Dans les états de la machine parente :
on: {
'FILES.ADD': {
actions: enqueueActions(({ enqueue, event }) => {
for (const file of event.files) {
enqueue.spawnChild('uploadMachine', {
id: `upload-${file.name}`,
input: { file },
syncSnapshot: true,
})
}
}),
},
},Chaque enfant engendré est un acteur totalement indépendant, avec son propre état, sa propre logique de réessai et son propre cycle de vie. L'échec d'un téléversement n'affecte pas les autres. Et comme uploadMachine est une machine autonome, vous pouvez la tester unitairement sans jamais construire de tunnel de paiement.
Étape 8 : état global avec createActorContext
useMachine limite l'acteur à un seul composant. Lorsque plusieurs composants éloignés ont besoin de la même machine — un badge panier dans l'en-tête, un récapitulatif en barre latérale, le formulaire de paiement — remontez-la avec createActorContext.
// src/machines/checkout.context.tsx
'use client'
import { createActorContext } from '@xstate/react'
import { checkoutMachine } from './checkout.machine'
export const CheckoutMachineContext = createActorContext(checkoutMachine)Enveloppez votre arbre une seule fois :
export function Providers({ children }: { children: React.ReactNode }) {
return <CheckoutMachineContext.Provider>{children}</CheckoutMachineContext.Provider>
}Puis lisez-la n'importe où — et c'est ici que la performance entre en jeu :
export function CartBadge() {
// useSelector s'abonne à UNE tranche : ce composant ne se re-rend que
// lorsque cartTotal change réellement, pas à chaque transition.
const total = CheckoutMachineContext.useSelector((s) => s.context.cartTotal)
const send = CheckoutMachineContext.useActorRef().send
return <button onClick={() => send({ type: 'SUBMIT' })}>Panier : {total} TND</button>
}Privilégiez useSelector plutôt que de récupérer le snapshot entier. Une machine dans une boucle de réessai transitionne plusieurs fois par seconde ; un composant qui ne sélectionne que cartTotal dort pendant tout ce temps. C'est le même principe que les sélecteurs de Zustand et Jotai, et c'est ce qui sépare une application fluide d'une application saccadée.
Étape 9 : persister l'état entre les rechargements
Un utilisateur rafraîchit la page en plein paiement. Avec useState, tout est perdu. Les acteurs XState sérialisent l'intégralité de leur snapshot — état courant, contexte et acteurs enfants compris.
'use client'
import { useMachine } from '@xstate/react'
import { checkoutMachine } from '@/machines/checkout.machine'
const STORAGE_KEY = 'checkout-snapshot'
function loadSnapshot() {
if (typeof window === 'undefined') return undefined
const raw = window.localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : undefined
}
export function PersistentCheckout() {
const [snapshot, send, actorRef] = useMachine(checkoutMachine, {
snapshot: loadSnapshot(),
})
useEffect(() => {
const sub = actorRef.subscribe(() => {
const persisted = actorRef.getPersistedSnapshot()
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(persisted))
})
return () => sub.unsubscribe()
}, [actorRef])
// ...
}Deux points méritent d'être soulignés.
getPersistedSnapshot() n'est pas le même objet que snapshot. C'est une représentation sérialisable, débarrassée des fonctions et des références vivantes, conçue pour survivre à JSON.stringify — cette distinction piège presque tout le monde la première fois.
Et restaurer n'est pas rejouer. La machine reprend dans l'état persisté ; elle ne rejoue pas les transitions qui l'y ont menée. Un snapshot enregistré pendant submitting restaurera donc dans submitting et ré-invoquera immédiatement chargeCard. Pour un tunnel de paiement, c'est le mauvais comportement — vous débiteriez deux fois. Ne persistez que depuis des états sûrs, ou ajoutez une garde d'entrée qui redirige un état submitting restauré vers un état « vérifier le statut de la commande » interrogeant votre API pour savoir si le débit a déjà eu lieu.
Étape 10 : débogage visuel avec l'inspecteur Stately
C'est la fonctionnalité qui convainc les collègues sceptiques. Passez une option inspect et regardez votre machine tourner sous forme de diagramme vivant.
'use client'
import { createBrowserInspector } from '@statelyai/inspect'
const inspector =
process.env.NODE_ENV === 'development' ? createBrowserInspector() : undefined
export function Checkout() {
const [snapshot, send] = useMachine(checkoutMachine, {
inspect: inspector?.inspect,
})
// ...
}Lancez l'application : un onglet s'ouvre avec votre diagramme d'états. Chaque événement, chaque évaluation de garde et chaque changement de contexte s'anime en temps réel. Déboguer un flux bloqué cesse d'être une fouille archéologique de console.log — vous voyez littéralement quelle transition s'est déclenchée ou non, et quelle garde l'a bloquée.
Gardez l'inspecteur derrière le test NODE_ENV pour qu'il ne parte jamais en production.
Tester votre machine
Parce qu'une machine est un objet pur et agnostique du framework, vous pouvez tester toute votre logique sans rendre le moindre composant React. Voici un test avec Vitest :
import { describe, it, expect } from 'vitest'
import { createActor, fromPromise, waitFor } from 'xstate'
import { checkoutMachine } from './checkout.machine'
describe('checkoutMachine', () => {
it('refuse de soumettre un panier invalide', () => {
const actor = createActor(checkoutMachine).start()
actor.send({ type: 'SUBMIT' })
// La garde a bloqué : on n'a jamais quitté editing
expect(actor.getSnapshot().matches('editing')).toBe(true)
})
it('débite la carte et atteint success', async () => {
// provide() remplace un vrai acteur par un faux, sans bibliothèque de mock
const testMachine = checkoutMachine.provide({
actors: {
chargeCard: fromPromise(async () => ({ orderId: 'ord_123' })),
},
})
const actor = createActor(testMachine).start()
actor.send({ type: 'EMAIL.CHANGE', email: 'a@b.com' })
actor.send({ type: 'CART.UPDATE', total: 100 })
actor.send({ type: 'SUBMIT' })
// waitFor résout dès que le prédicat passe
const finalState = await waitFor(actor, (s) => s.matches('success'))
expect(finalState.context.orderId).toBe('ord_123')
})
})machine.provide() est le super-pouvoir des tests. Il renvoie une nouvelle machine dont les implémentations sont remplacées, sans toucher au diagramme d'états. Pas de vi.mock, pas d'interception de module, pas de framework d'injection de dépendances. Votre acteur de paiement devient un stub de deux lignes, et la machine testée reste par ailleurs exactement celle qui tourne en production.
Dépannage
« Les événements sont ignorés et rien ne se passe. » C'est XState qui fonctionne correctement. Un événement sans transition correspondante dans l'état courant est volontairement sans effet. Ouvrez l'inspecteur et vérifiez dans quel état vous êtes réellement — en général vous avez envoyé SUBMIT alors que vous étiez déjà dans submitting.
« Mon action se déclenche mais le contexte ne change pas. » Vous avez presque certainement muté le contexte directement. assign doit retourner une nouvelle valeur ; context.retries++ dans une action ne sert à rien. Retournez plutôt { retries: context.retries + 1 }.
« TypeScript n'infère pas mes types d'événements. » Votre union d'événements doit être une union discriminée sur type, et elle doit être enregistrée dans setup({ types: { events: {} as MesEvenements } }). Le cast {} as T est volontaire : vous fournissez un type, pas une valeur.
« L'acteur invoqué s'exécute deux fois en développement. » C'est le double montage du Strict Mode de React 18. C'est attendu et cela n'arrive pas en production, mais c'est un vrai signal d'alarme pour les effets de bord non idempotents comme les paiements. Rendez votre endpoint idempotent avec une clé de requête.
« Le snapshot persisté ne se restaure pas. » Vous avez probablement stocké snapshot au lieu de actorRef.getPersistedSnapshot(). Seul le second est sérialisable.
Quand ne pas utiliser XState
Conseil honnête : XState n'est pas gratuit. Il ajoute une charge conceptuelle dont une application simple n'a pas besoin.
Évitez-le pour les interrupteurs booléens, les formulaires simples et la simple récupération de données serveur — useState gère très bien les deux premiers cas, et TanStack Query gère le troisième bien mieux qu'une machine. Sortez XState quand la logique comporte de véritables modes au comportement distinct, quand les transitions entre eux obéissent à des règles, et quand se tromper sur ces règles coûte de l'argent ou des données réelles : tunnels de paiement, assistants multi-étapes, lecteurs multimédias, glisser-déposer, onboarding et orchestration d'agents.
Une bonne heuristique : si vous avez déjà dessiné le flux au tableau blanc sous forme de boîtes et de flèches, XState vous permet de livrer le tableau blanc.
Prochaines étapes
- Explorez
@xstate/store, une alternative bien plus légère pour un état partagé simple, sans la machinerie des diagrammes d'états - Modélisez des états parallèles pour des préoccupations indépendantes qui s'exécutent en même temps, comme un lecteur multimédia suivant séparément la lecture et le volume
- Utilisez les acteurs
fromCallbackpour envelopper des connexions WebSocket et des émetteurs d'événements - Essayez l'éditeur visuel Stately Studio, qui fait l'aller-retour entre code et diagramme
- Comparez cette approche avec nos tutoriels sur Zustand pour la gestion d'état Next.js et l'état atomique avec Jotai
- Appliquez le modèle acteur aux agents IA avec notre guide LangGraph.js et les agents à état
Conclusion
Vous avez construit un tunnel de paiement incapable d'entrer dans un état impossible — parce que les états impossibles n'existent pas dans le modèle. En chemin, vous avez couvert l'API setup() et son inférence de types, les acteurs promesses avec fromPromise, les gardes qui pilotent automatiquement vos boutons désactivés, les réessais avec délai exponentiel et nettoyage structurel, les acteurs enfants pour le travail parallèle, createActorContext et ses sélecteurs à grain fin, ainsi que la persistance des snapshots.
Le vrai basculement, c'est l'endroit où vit la logique. Dans la version useState, vos règles métier sont éparpillées entre gestionnaires d'événements, effets et conditions JSX — vous ne pouvez jamais toutes les voir d'un coup. Dans la version XState, elles vivent dans un objet unique que vous pouvez lire de haut en bas, visualiser sous forme de diagramme, tester sans DOM et montrer à un designer qui le comprendra réellement.
Les booléens décrivent ce que votre interface est. Les machines à états décrivent ce qu'elle est autorisée à devenir. Dès que la correction compte, c'est à cette seconde question qu'il vaut la peine de répondre.