Parcours de formulaire par machine à états
Formulaire en 12 étapes. Validation conditionnelle. Le bouton Retour doit fonctionner.
Modéliser un parcours public d’Investor Intake avec XState, onze conditions de garde de validation, un contexte de formulaire préservé et des tests navigateur.
6 min de lecturePar Abdullah Raheel
Les transitions faisaient partie du produit
Un compteur d’étapes ne suffisait qu’au début. À la cinquième étape, le parcours suivait la position, les données accumulées, les erreurs de validation, le chargement, les étapes terminées et les exigences conditionnelles. À la dixième étape, un composant comptait environ 800 lignes et six endroits différents répondaient à la question « cet utilisateur peut-il continuer ? ».
Afficher douze écrans était simple. Maintenir la cohérence des transitions vers l’avant, vers l’arrière, asynchrones et d’envoi sans perdre les saisies précédentes ne l’était pas.
Un état organisé peut encore autoriser des états impossibles
useState permettait au formulaire de combiner des valeurs qui ne devaient pas coexister : un écran ultérieur après l’effacement de données prérequises, une transition vers l’arrière pendant l’envoi ou un bouton valide alors qu’un champ conditionnel visible était vide. useReducer pouvait centraliser les mises à jour, mais acceptait toujours toute action implémentée par le reducer depuis n’importe quelle étape actuelle, sauf à recréer un graphe de transitions dans chaque branche.
XState v5 avec @xstate/react a rendu ce graphe explicite. Un événement peut toujours être envoyé, mais une transition non définie ou rejetée par une condition de garde ne déplace pas la machine.
Rendre explicite le contrat du parcours
La machine utilisait sept événements : NEXT, PREVIOUS, GO_TO_STEP, UPDATE_FORM_DATA, SET_CREATED_BUYER_ID, SUBMIT et RESET. Le contexte contenait currentStep, formData, errors, isLoading, investorId et createdBuyerId.
CONTACT_PREFERENCES: {
on: {
NEXT: {
target: 'TARGET_MARKETS',
guard: 'CAN_PROGRESS_FROM_CONTACT_PREFERENCES'
},
PREVIOUS: 'INTRO',
UPDATE_FORM_DATA: { actions: 'UPDATE_FORM_DATA' }
}
}NEXT était protégé par une condition de garde. PREVIOUS restait disponible hors de l’envoi afin que l’utilisateur puisse corriger des données antérieures sans réinitialiser le contexte du formulaire. UPDATE_FORM_DATA fusionnait uniquement les champs modifiés dans le contexte accumulé.
Placer la validation à la frontière de transition
Onze conditions de garde suivaient la convention CAN_PROGRESS_FROM_[STEP_NAME]. Un schéma Zod exigeait au moins un numéro de téléphone ou une adresse e-mail et ne validait un numéro américain que s’il était présent. Les marchés cibles, types de biens et stratégies exigeaient chacun une sélection. Les écrans de plage exigeaient au moins un minimum ou un maximum.
const canGoNext = state.can({ type: "NEXT" })
CAN_PROGRESS_FROM_CONTACT_CARD: ({ context }) =>
Boolean(context.createdBuyerId)La condition de garde Contact Card renvoyait initialement toujours true. Une correction ultérieure l’a liée à createdBuyerId afin que la machine ne puisse pas quitter cet état avant la réussite de la création de l’acheteur côté serveur. Le même contrat state.can({ type: "NEXT" }) pilotait l’état du bouton et la transition elle-même.
Empêcher la vérification asynchrone d’entrer en conflit avec le formulaire
Le formulaire intégrait aussi des clients d’API générés pour la vérification des contacts et la création des acheteurs. Les contrôles du téléphone et de l’adresse e-mail étaient temporisés. Un identifiant de requête strictement croissant ignorait une réponse obsolète lorsqu’une valeur plus récente avait déjà été saisie. Les recommandations de marchés cibles étaient dédupliquées selon leurs coordonnées avant d’entrer dans le contexte de la machine.
La création de l’acheteur se produisait à la frontière Contact Card. SET_CREATED_BUYER_ID stockait dans le contexte de la machine l’identifiant confirmé côté serveur. Les écrans de préférences suivants utilisaient cet identifiant pour leurs mises à jour d’API.
Garder les composants React indépendants du graphe de routes
Un fournisseur enveloppait useMachine et exposait currentStep, formData, canGoNext, isSubmitting, handleNext, handlePrevious, handleUpdateFormData et l’action sur l’identifiant de l’acheteur. Les composants d’étape affichaient leurs champs et envoyaient les mises à jour sans connaître la route précédente ou suivante.
La machine gère la navigation. Chaque étape React affiche les champs, met à jour le contexte et envoie des événements. Je n’utiliserais pas XState pour un formulaire en trois étapes. Ici, les douze écrans, la validation conditionnelle et les transitions dépendantes du serveur justifiaient la configuration supplémentaire d’un graphe explicite.
Tester les règles indépendamment des écrans
L’implémentation a ajouté 538 lignes de couverture unitaire pour les schémas et les transitions directes. Ces tests envoyaient des événements à la machine et vérifiaient l’état sans monter de composants React. Les 359 autres lignes de couverture Playwright testaient le parcours complet, les règles de téléphone et d’adresse e-mail, la création de l’acheteur côté serveur et la préservation des données pendant la navigation arrière.
- 12 étapes
- sept événements
- 11 conditions de garde de progression nommées
- un fichier de machine qui contrôle la navigation
- tests des schémas et de la machine séparés des tests d’interaction dans le navigateur

