XState : Gérer l'État Complexe avec les Machines à États
Contexte du cours : Maîtriser la Gestion d'État Avancée : Architectures Robustes pour SPAs Modernes
Introduction : L'Indomptable Complexité de l'État
Dans le monde des applications web modernes, en particulier les Single Page Applications (SPAs), la gestion de l'état est un défi central. À mesure que nos applications grandissent, la logique d'état peut devenir implicite, difficile à suivre et propice aux bugs. Un simple composant peut avoir plusieurs modes (chargement, succès, erreur, édition, vide), chacun réagissant différemment aux interactions de l'utilisateur ou aux réponses du serveur. Sans une approche structurée, nous nous retrouvons souvent avec :
- Des conditions
if/elseou desswitchimbriqués partout, rendant le code illisible et difficile à maintenir. - Des "états impossibles", où l'application se retrouve dans une combinaison d'états qui ne devrait pas exister (ex:
isLoadingesttrueethasErroresttruesimultanément alors que cela devrait être mutuellement exclusif). - Une difficulté à communiquer la logique métier complexe aux autres membres de l'équipe.
Pour pallier ces problèmes, le concept de machines à états finis (Finite State Machines - FSM) et plus spécifiquement de statecharts offre une solution élégante et puissante. XState est une bibliothèque JavaScript et TypeScript qui implémente la spécification des statecharts W3C, nous permettant de modéliser explicitement la logique d'état de nos applications de manière robuste et compréhensible.
Dans cette leçon, nous allons explorer les principes fondamentaux des FSM et des statecharts, comprendre comment XState les met en œuvre, et apprendre à construire des machines à états pour gérer l'état complexe de nos SPAs modernes.
Pourquoi XState ? Le Problème qu'il Résout
La gestion d'état "manuelle" avec des drapeaux booléens ou des énumérations peut rapidement devenir ingérable. Considérez un composant qui doit charger des données :
- Il est
idle(inactif). - L'utilisateur clique sur un bouton ou le composant monte, il devient
loading. - Le chargement peut réussir (
success) ou échouer (error). - Depuis
successouerror, l'utilisateur pourrait vouloir recharger (retrying).
Chacun de ces états a des implications sur l'UI et sur les actions possibles. Voici les problèmes classiques que XState aide à résoudre :
- États implicites vs. explicites : Souvent, l'état de notre application est déduit par une combinaison de variables (
isLoading && !hasError). XState rend chaque état explicite et distinct. - Transitions non autorisées : Que se passe-t-il si un utilisateur tente de soumettre un formulaire alors qu'il est déjà en cours de soumission ? Ou s'il clique sur "suivant" alors que la validation a échoué ? XState permet de définir précisément quelles transitions sont permises à partir de quel état.
- Gestion des effets secondaires : Les appels API, les timers, les souscriptions doivent être gérés avec soin, en s'assurant qu'ils sont démarrés au bon moment et annulés correctement lorsque l'état change. XState offre des mécanismes intégrés pour cela.
- Observabilité et Débogage : Une machine à états est par nature prévisible et facile à visualiser, ce qui simplifie le débogage et la compréhension du comportement de l'application.
Fondamentaux des Machines à États Finis (FSM) et des Statecharts
Qu'est-ce qu'une FSM ?
Une Machine à États Finis est un modèle mathématique de calcul qui peut se trouver exactement dans l'un d'un nombre fini d'états à un moment donné. Elle ne peut être que dans un état à la fois. La FSM passe d'un état à un autre en réponse à certains événements. La transition est appelée un changement d'état.
Les composants clés d'une FSM sont :
- États (States) : Un nombre fini de conditions distinctes dans lesquelles la machine peut se trouver (ex:
idle,loading,success,error). - Événements (Events) : Des déclencheurs qui provoquent des transitions entre les états (ex:
FETCH,RESOLVE,REJECT). - Transitions (Transitions) : Le passage d'un état à un autre en réponse à un événement spécifique.
- État Initial (Initial State) : L'état dans lequel la machine commence son exécution.
Limitations des FSM et l'Apparition des Statecharts
Les FSM traditionnelles sont excellentes pour des logiques simples, mais elles peuvent devenir verbeuses pour des systèmes complexes. David Harel a introduit le concept de Statecharts en 1987 pour étendre les FSM avec des fonctionnalités puissantes :
- États Imbriqués (Nested States / Hierarchical States) : Permet de regrouper des états liés sous un état parent. Par exemple, un état
editingpourrait avoir des sous-étatsvalidetinvalid. Cela réduit la complexité en évitant de répéter les transitions et les actions pour chaque sous-état. - États Parallèles (Parallel States / Orthogonal Regions) : Permet à plusieurs machines à états de fonctionner simultanément et indépendamment, mais au sein du même état parent. Par exemple, un utilisateur pourrait être
authenticatedtout en ayant unpanier-videoupanier-plein. - Actions : Des effets secondaires qui se produisent lors d'une transition ou lors de l'entrée/sortie d'un état.
entryactions : Exécutées lors de l'entrée dans un état.exitactions : Exécutées lors de la sortie d'un état.onactions : Exécutées lors d'une transition spécifique.
- Gardiens (Guards / Conditions) : Des fonctions qui déterminent si une transition doit avoir lieu. Par exemple, une transition
SUBMITne se produit que siisValidesttrue. - Activités (Activities) : Des effets secondaires de longue durée qui s'exécutent pendant que la machine est dans un état.
Les statecharts sont un moyen incroyablement puissant de modéliser des systèmes réactifs complexes de manière formelle et visuelle.
Introduction à XState
XState est une bibliothèque agnostique de framework (peut être utilisée avec React, Vue, Svelte, Angular ou même du JavaScript vanilla) qui offre une implémentation robuste de la spécification des statecharts W3C.
Installation
npm install xstate
# ou
yarn add xstate
Concepts Clés de XState
createMachine(...): La fonction principale pour définir votre statechart. Elle prend un objet de configuration qui décrit les états, les événements, les transitions, etc.interpret(...): Crée une instance "exécutable" de votre machine à états. C'est l'interface avec laquelle vous interagirez pour envoyer des événements et observer les changements d'état.service.send(...): Envoie un événement à la machine en cours d'exécution pour potentiellement provoquer une transition.service.onTransition(...): Permet de s'abonner aux changements d'état de la machine.
Créer une Machine Simple : Chargement de Données
Commençons par un exemple concret : une machine qui gère le cycle de vie d'un appel API de chargement de données.
import { createMachine, interpret } from 'xstate';
// 1. Définir la machine
const dataFetchMachine = createMachine({
// Un identifiant unique pour cette machine
id: 'dataFetcher',
// L'état initial de la machine
initial: 'idle',
// Définition de tous les états possibles
states: {
idle: {
// Lorsqu'on est dans l'état 'idle', envoie l'événement 'FETCH' pour passer à 'loading'
on: {
FETCH: 'loading'
}
},
loading: {
// Lorsqu'on est dans l'état 'loading'
on: {
// En cas de succès (événement 'RESOLVE'), passe à 'success'
RESOLVE: 'success',
// En cas d'échec (événement 'REJECT'), passe à 'error'
REJECT: 'error'
}
},
success: {
// Lorsqu'on est dans l'état 'success'
on: {
// L'utilisateur peut re-charger les données
FETCH: 'loading'
}
},
error: {
// Lorsqu'on est dans l'état 'error'
on: {
// L'utilisateur peut tenter une nouvelle fois
RETRY: 'loading',
// Ou déclencher un nouveau fetch (similaire à RETRY dans ce cas simple)
FETCH: 'loading'
}
}
}
});
// 2. Interpréter la machine pour créer un service exécutable
const dataFetcherService = interpret(dataFetchMachine)
.onTransition((state) => {
// Chaque fois que l'état de la machine change, cette fonction est appelée
console.log(`Current state: ${state.value}`);
// state.context contiendrait les données étendues si nous en avions
})
.start(); // N'oubliez pas de démarrer le service !
console.log('--- Initial State ---');
console.log(`Current state: ${dataFetcherService.state.value}`); // logs 'idle'
console.log('\n--- Fetching Data ---');
dataFetcherService.send('FETCH'); // Envoie l'événement 'FETCH'
// Attendons un peu pour simuler le temps de chargement
setTimeout(() => {
const success = Math.random() > 0.5; // 50% de chance de succès
if (success) {
console.log('API call resolved successfully!');
dataFetcherService.send('RESOLVE'); // Envoie l'événement 'RESOLVE'
} else {
console.log('API call rejected!');
dataFetcherService.send('REJECT'); // Envoie l'événement 'REJECT'
}
}, 1000);
// Pour illustrer d'autres transitions après le succès/échec
setTimeout(() => {
if (dataFetcherService.state.value === 'success') {
console.log('\n--- Re-fetching after success ---');
dataFetcherService.send('FETCH');
setTimeout(() => dataFetcherService.send('RESOLVE'), 500); // Ré-résoudre rapidement
} else if (dataFetcherService.state.value === 'error') {
console.log('\n--- Retrying after error ---');
dataFetcherService.send('RETRY');
setTimeout(() => dataFetcherService.send('RESOLVE'), 500); // Résoudre cette fois
}
}, 3000);
Explication du code :
- Nous importons
createMachineetinterpretdexstate. createMachineprend un objet avec unid(pour l'identification) et uninitial(l'état de départ).- L'objet
statescontient toutes les définitions d'états. Chaque clé est le nom d'un état. - Dans chaque état, l'objet
ondéfinit les transitions : les clés sont les événements, et les valeurs sont les états vers lesquels transiter. interpret(dataFetchMachine).start()crée et démarre une instance de la machine.onTransitionest un callback qui est déclenché chaque fois que la machine change d'état, nous permettant deconsole.logl'état actuel pour le débogage.service.send('EVENT_NAME')est la méthode pour envoyer des événements à la machine, la faisant potentiellement transiter vers un nouvel état.
Cet exemple simple montre la structure de base d'une machine XState et comment les événements déclenchent des transitions d'un état à l'autre de manière explicite.
Concepts Avancés avec XState : Formulaire de Soumission
Pour un exemple plus complexe, prenons le cas d'un formulaire de soumission. Il doit gérer la validation, la soumission asynchrone, et les différents retours d'API.
import { createMachine, interpret, assign } from 'xstate';
// Simule un appel API de soumission
const submitForm = async (formData) => {
console.log('Submitting form data:', formData);
return new Promise((resolve, reject) => {
setTimeout(() => {
if (formData.email && formData.email.includes('@') && formData.password && formData.password.length >= 6) {
if (Math.random() > 0.7) { // 30% de chance d'échec simulé
reject({ message: 'Server error' });
} else {
resolve({ success: true, message: 'Form submitted successfully!' });
}
} else {
reject({ message: 'Client-side validation failed (simulated)' });
}
}, 1500);
});
};
// Machine de formulaire
const formMachine = createMachine({
id: 'form',
initial: 'editing',
// Le "context" est l'état étendu de la machine, comme les données du formulaire, les messages d'erreur
context: {
formData: {
email: '',
password: ''
},
errorMessage: undefined,
successMessage: undefined
},
states: {
editing: {
initial: 'invalid', // Sous-état initial de 'editing'
// Actions à exécuter à l'entrée de l'état 'editing'
entry: assign({
errorMessage: undefined, // Efface les erreurs précédentes
successMessage: undefined // Efface les succès précédents
}),
states: {
// L'état 'invalid' (le formulaire n'est pas prêt à être soumis)
invalid: {
on: {
// L'événement 'CHANGE' déclenche une action pour mettre à jour le contexte
// et évalue une condition pour passer à 'valid'
CHANGE: {
actions: assign((context, event) => ({
formData: {
...context.formData,
[event.name]: event.value
}
})),
target: 'valid', // Tente de passer à 'valid'
cond: 'isValidForm' // Si la condition 'isValidForm' est vraie
}
}
},
// L'état 'valid' (le formulaire peut être soumis)
valid: {
on: {
// L'événement 'CHANGE' met à jour le contexte et peut revenir à 'invalid'
CHANGE: {
actions: assign((context, event) => ({
formData: {
...context.formData,
[event.name]: event.value
}
})),
target: 'invalid', // Tente de passer à 'invalid'
cond: 'isInvalidForm' // Si la condition 'isInvalidForm' est vraie
},
// L'événement 'SUBMIT' envoie le formulaire si l'on est dans l'état 'valid'
SUBMIT: 'submitting'
}
}
}
},
submitting: {
// 'invoke' est utilisé pour les services asynchrones (promesses, callbacks, observables)
invoke: {
id: 'submitService',
src: (context) => submitForm(context.formData), // La fonction à appeler
onDone: { // Si la promesse est résolue
target: 'success',
actions: assign({
successMessage: (context, event) => event.data.message
})
},
onError: { // Si la promesse est rejetée
target: 'failure',
actions: assign({
errorMessage: (context, event) => event.data.message || 'Submission failed.'
})
}
},
on: {
// Ne permet pas de soumettre à nouveau pendant la soumission
SUBMIT: undefined // Ignorer l'événement SUBMIT dans cet état
}
},
success: {
on: {
// Après le succès, on peut revenir à l'édition (par exemple, pour un autre formulaire)
RESET: 'editing'
}
},
failure: {
on: {
// Après un échec, on peut tenter de nouveau
RETRY: 'submitting',
// Ou revenir à l'édition pour modifier le formulaire
EDIT: 'editing'
}
}
},
// Définition des gardiens (conditions)
guards: {
isValidForm: (context) => {
const { email, password } = context.formData;
return email.includes('@') && password.length >= 6;
},
isInvalidForm: (context) => {
// Un simple inverse pour l'exemple, dans un vrai scénario la logique serait distincte
return !formMachine.options.guards.isValidForm(context);
}
}
});
// Interpréter et démarrer la machine
const formService = interpret(formMachine)
.onTransition((state) => {
console.log(
`State: ${JSON.stringify(state.value)}, Context: ${JSON.stringify(state.context)}`
);
})
.start();
console.log('\n--- Initial Form State ---');
// Simuler la saisie de l'utilisateur
console.log('\n--- User typing email (invalid) ---');
formService.send({ type: 'CHANGE', name: 'email', value: 'test' }); // 'editing.invalid'
console.log('\n--- User typing password (still invalid) ---');
formService.send({ type: 'CHANGE', name: 'password', value: '123' }); // 'editing.invalid'
console.log('\n--- User typing valid email ---');
formService.send({ type: 'CHANGE', name: 'email', value: 'test@example.com' }); // 'editing.invalid' -> 'editing.valid'
console.log('\n--- User typing valid password ---');
formService.send({ type: 'CHANGE', name: 'password', value: 'password123' }); // Should still be 'editing.valid'
console.log('\n--- Attempting to submit form ---');
formService.send('SUBMIT'); // Transition vers 'submitting'
// Simuler l'interaction après la soumission
setTimeout(() => {
if (formService.state.value === 'success') {
console.log('\n--- Form submitted successfully, resetting ---');
formService.send('RESET');
} else if (formService.state.value === 'failure') {
console.log('\n--- Form submission failed, retrying ---');
formService.send('RETRY');
}
}, 4000);
// Pour s'assurer que les logs finaux sont visibles
setTimeout(() => {
console.log('\n--- Final state after interactions ---');
console.log(
`Final State: ${JSON.stringify(formService.state.value)}, Context: ${JSON.stringify(formService.state.context)}`
);
formService.stop();
}, 8000);
Explication des concepts avancés :
context(État Étendu) : Lecontextde la machine est un objet qui contient toutes les données que la machine doit "retenir" et sur lesquelles elle opère (ici,formData,errorMessage,successMessage). Il est modifiable via des actions.assign(Action) : L'actionassignest utilisée pour mettre à jour lecontextde la machine de manière immuable. C'est une fonction utilitaire fournie par XState.- États Imbriqués (Nested States) : L'état
editingcontient les sous-étatsinvalidetvalid. Cela modélise que le formulaire est en cours d'édition, mais il a aussi un état de validité.- Les événements définis sur un état parent (
editing) sont hérités par les sous-états, sauf s'ils sont surchargés.
- Les événements définis sur un état parent (
guards(Gardiens / Conditions) : Les fonctions définies dans l'objetguards(ex:isValidForm,isInvalidForm) sont utilisées avec la propriétéconddans une transition. La transition ne se produit que si la fonction du gardien retournetrue.- Remarquez comment
CHANGEpeut passer deediting.invalidàediting.validou vice-versa en fonction du résultat de la validation.
- Remarquez comment
invoke(Services Asynchrones) :- L'état
submittingutiliseinvokepour gérer l'appel API asynchronesubmitForm. srcpointe vers la fonction qui retourne une promesse (ou un observable, etc.).onDoneetonErrordéfinissent les transitions à prendre lorsque le serviceinvokeréussit ou échoue, respectivement. Ils reçoivent les données résolues ou rejetées par le service dans leurevent.data.
- L'état
- Actions
entryetexit: L'étateditinga une actionentryqui efface les messages d'erreur et de succès précédents lorsque l'on entre dans cet état. undefinedpour ignorer les événements : Dans l'étatsubmitting,SUBMIT: undefinedsignifie que si l'événementSUBMITest reçu pendant que le formulaire est en cours de soumission, il sera simplement ignoré, évitant ainsi des soumissions multiples accidentelles.
Cet exemple démontre comment XState permet de modéliser des interactions complexes avec clarté, en gérant à la fois la logique synchrone (validation) et asynchrone (appels API), ainsi que les différentes phases d'un processus.
Intégration avec les Frameworks Front-End
XState est agnostique, mais il propose des paquets d'intégration pour les frameworks populaires :
@xstate/reactavec le hookuseMachine.@xstate/vueavec la composition APIuseMachine.@xstate/svelteavecuseMachine.
Ces intégrations simplifient considérablement l'utilisation de machines XState dans vos composants, en connectant automatiquement l'état de la machine au cycle de vie du composant et en fournissant des valeurs réactives. Le principe est toujours de séparer la logique d'état (dans la machine XState) de la représentation UI (dans le composant).
Avantages de XState
- Clarté et Lisibilité : Les statecharts sont une représentation visuelle et textuelle de votre logique d'état, facile à comprendre et à discuter.
- Prévention des États Impossibles : En définissant explicitement les états et les transitions, vous rendez impossible pour l'application de se trouver dans un état incohérent.
- Facilité de Test : Les machines XState sont des unités pures, prévisibles et faciles à tester de manière isolée.
- Collaboration Améliorée : Les outils de visualisation (comme Stately AI) permettent de générer des diagrammes à partir de votre code, facilitant la communication entre développeurs, designers et chefs de produit.
- Robustesse et Prédictibilité : Le comportement de l'application devient déterministe et prévisible.
Quand Utiliser XState ?
XState n'est pas forcément nécessaire pour chaque petit composant. Il brille particulièrement dans les scénarios suivants :
- Logique métier complexe : Lorsque les règles de transition entre les états sont nombreuses et interdépendantes.
- Flux utilisateur multi-étapes : Wizards, formulaires complexes, processus d'inscription ou de commande.
- Interactions asynchrones avec de nombreux états intermédiaires : Appels API avec des états
idle,loading,success,error,retrying, etc. - Composants avec des modes d'interaction distincts : Un composant d'édition qui peut être
viewing,editing,saving,deleting.
Conclusion
XState, en implémentant les statecharts, offre une approche déclarative et robuste pour gérer l'état complexe de vos applications. En vous forçant à modéliser explicitement les états, les événements et les transitions, vous prévenez les erreurs courantes, améliorez la lisibilité et la maintenabilité de votre code, et facilitez la collaboration au sein de votre équipe.
Bien que la courbe d'apprentissage puisse sembler un peu raide au début, l'investissement dans la compréhension des FSM et des statecharts, et leur mise en œuvre avec XState, portera ses fruits en vous permettant de construire des architectures plus fiables et prévisibles pour vos SPAs modernes. Nous vous encourageons vivement à explorer davantage la documentation officielle de XState et l'éditeur Stately AI pour approfondir votre maîtrise de cet outil puissant.