12k
All articles

Créer une application de quiz avec une machine à états

Créez une app de quiz avec XState v5 et React. Modélisez quatre écrans en machine à états, remplacez les booléens et protégez le résultat final.

OpenReplay Team
OpenReplay Team
Créer une application de quiz avec une machine à états

Une machine à états remplace un empilement de drapeaux booléens par un unique état nommé, de sorte qu’un composant React ne peut jamais se trouver que dans l’un des écrans que vous avez délibérément définis.

Cela s’installe insidieusement. Un écran commence avec un isLoading, gagne un hasAnswered, puis un isFinished, et à un moment donné plus personne ne sait quelles combinaisons sont légales. Cet article modélise une petite fonctionnalité, un quiz, sous la forme d’une machine XState v5 à quatre états, et montre ce que cela apporte par rapport à la version à base de drapeaux. Pour une introduction plus large à la bibliothèque, l’aperçu de XState d’OpenReplay constitue un bon point de départ, même si son code est antérieur à la v5 ; ici, nous construisons un fichier unique, de bout en bout. Le code cible XState v5 et la version 6 de @xstate/react.

Points clés à retenir

  • Trois drapeaux booléens décrivent huit combinaisons, alors qu’un quiz ne comporte que quatre écrans légaux, ce qui laisse quatre états que votre interface peut atteindre et que votre conception n’a jamais prévus.
  • Dans XState, les états décrivent ce que l’utilisateur peut faire à l’instant présent, tandis que le contexte contient les données sur lesquelles ces états opèrent : « answered » est un état, mais la liste des questions, l’index courant et le score relèvent du contexte.
  • Dans XState v5, un garde (guard) est une fonction recevant { context, event } qui répond vrai ou faux, et une réponse fausse signifie que la transition sur laquelle il porte est ignorée.
  • Ajouter une étape de révision à un composant fondé sur des drapeaux implique un quatrième drapeau et seize combinaisons ; l’ajouter à une machine implique un nouvel état et ses transitions.

Le quiz et ses quatre états

Le quiz comporte exactement quatre écrans, et chacun correspond à un état nommé. idle : l’écran de démarrage, rien n’est chargé, un seul bouton. question : une question est affichée et les options sont cliquables. answered : l’utilisateur a choisi une option, le retour est visible et un bouton Next apparaît. results : le score final, avec une proposition de recommencer. Chaque rendu que le composant produira un jour appartient à l’un de ces quatre états.

Pourquoi les drapeaux booléens s’effondrent-ils ?

Trois drapeaux booléens, isLoading, hasAnswered et isFinished, décrivent huit combinaisons possibles, alors que le quiz ne comporte que quatre écrans légaux. Il reste donc quatre états que votre interface peut physiquement atteindre et que votre conception n’a jamais prévus.

isLoadinghasAnsweredisFinishedÉcran
falsefalsefalsequestion
truefalsefalsechargement de la question suivante
falsetruefalseanswered
falsefalsetrueresults
truetruefalseaucun (réponse pendant le chargement)
truefalsetrueaucun
falsetruetrueaucun
truetruetrueaucun

La première ligne illégale n’est pas hypothétique. Voici comment elle survient :

function goToNext() {
  setIsLoading(true);
  fetchQuestion(index + 1).then((q) => {
    setQuestion(q);
    setHasAnswered(false); // reset arrives only when the fetch resolves
    setIsLoading(false);
  });
}

L’utilisateur répond, clique sur Next, et jusqu’à ce que la requête soit résolue, le composant conserve simultanément isLoading: true et hasAnswered: true : un indicateur de chargement à côté de la réponse surlignée de la question précédente. Rien ne relie les deux drapeaux, donc rien ne l’empêche. Dans les rejeux de session (session replays) de parcours de type quiz ou assistant, cette catégorie de bug présente une signature reconnaissable : un écran qui ne devrait pas exister, par exemple une réponse surlignée sans question affichée, ce à quoi ressemble une combinaison de drapeaux non énumérée du point de vue de l’utilisateur.

Modéliser le quiz comme une machine à états dans React

La machine nomme les quatre états, les événements qui font passer de l’un à l’autre, et rien d’autre ; chaque transition est donc explicite et tout ce qui n’est pas listé est impossible. createMachine prend l’ensemble de la définition sous forme d’un seul objet :

import { createMachine, assign } from 'xstate';

const questions = [
  { text: '2 + 2?', options: ['3', '4'], answer: 1 },
  { text: 'Capital of France?', options: ['Paris', 'Lyon'], answer: 0 },
  { text: 'Largest planet?', options: ['Earth', 'Jupiter'], answer: 1 },
];

export const quizMachine = createMachine({
  id: 'quiz',
  initial: 'idle',
  context: { questions, currentIndex: 0, score: 0 },
  states: {
    idle: {
      on: { START: { target: 'question' } },
    },
    question: {
      on: {
        ANSWER: {
          target: 'answered',
          actions: assign({
            score: ({ context, event }) =>
              event.optionIndex === context.questions[context.currentIndex].answer
                ? context.score + 1
                : context.score,
          }),
        },
      },
    },
    answered: {
      on: {
        NEXT: {
          target: 'question',
          actions: assign({
            currentIndex: ({ context }) => context.currentIndex + 1,
          }),
        },
      },
    },
    results: {
      on: {
        RESTART: {
          target: 'idle',
          actions: assign({ currentIndex: 0, score: 0 }),
        },
      },
    },
  },
});

Un événement ANSWER reçu dans l’état results ne fait rien, par construction plutôt que par des contrôles if défensifs. Une lacune subsiste : rien n’atteint encore results. La section sur les gardes la comble.

Contexte contre état : une donnée n’est pas un état

Les états décrivent ce que l’utilisateur peut faire à l’instant présent ; le contexte contient les données sur lesquelles ces états opèrent. Pour ce quiz, « answered » est un état parce qu’il change quels contrôles sont actifs, tandis que la liste des questions, l’index courant et le score relèvent du contexte parce qu’ils changent ce qui est affiché à l’intérieur d’un écran, et non la nature de l’écran. Le critère est transposable : si une valeur change ce que l’utilisateur peut faire, modélisez-la comme un état ; si elle change ce qu’il voit à l’intérieur du même écran, placez-la dans le contexte et mettez-la à jour avec assign, dont le callback reçoit { context, event } comme montré ci-dessus. Coder les questions en dur permet de garder cet article focalisé ; pour les charger via le réseau, invoquez un acteur fromPromise dans un état de chargement et faites un assign à partir de event.output.

Brancher la machine dans un composant React

Le hook useMachine de @xstate/react renvoie trois éléments dans un tableau : l’instantané (snapshot) courant, une fonction send, et une référence à l’acteur en cours d’exécution. Le composant ci-dessous prend les deux premiers, effectue le rendu à partir de snapshot.value, et signale les actions de l’utilisateur sous forme d’événements au lieu de positionner des drapeaux.

import { useMachine } from '@xstate/react';
import { quizMachine } from './quizMachine';

export default function Quiz() {
  const [snapshot, send] = useMachine(quizMachine);
  const { questions, currentIndex, score } = snapshot.context;
  const question = questions[currentIndex];

  if (snapshot.value === 'idle')
    return <button onClick={() => send({ type: 'START' })}>Start quiz</button>;

  if (snapshot.value === 'results')
    return (
      <div>
        <p>Score: {score} / {questions.length}</p>
        <button onClick={() => send({ type: 'RESTART' })}>Play again</button>
      </div>
    );

  return (
    <div>
      <p>{question.text}</p>
      {question.options.map((option, i) => (
        <button
          key={option}
          disabled={snapshot.value === 'answered'}
          onClick={() => send({ type: 'ANSWER', optionIndex: i })}
        >
          {option}
        </button>
      ))}
      {snapshot.value === 'answered' && (
        <button onClick={() => send({ type: 'NEXT' })}>Next</button>
      )}
    </div>
  );
}

Il n’y a aucune comptabilité de drapeaux dans les gestionnaires d’événements. Le composant énonce des faits (« l’utilisateur a répondu ») et la machine décide de ce qu’ils signifient.

Comment protéger la condition de fin ?

Un garde est une petite vérification sans effet de bord. XState lui transmet le contexte de la machine et l’événement qui vient d’arriver, et il répond vrai ou faux. Une réponse fausse signifie que la transition sur laquelle il porte est ignorée. Pour router la dernière réponse vers results, remplacez la transition NEXT par un tableau de transitions gardées. XState parcourt ce tableau de haut en bas, utilise la première entrée dont le garde répond vrai, et n’atteint l’entrée non gardée en fin de liste que si aucune ne répond vrai :

    answered: {
      on: {
        NEXT: [
          {
            guard: ({ context }) =>
              context.currentIndex >= context.questions.length - 1,
            target: 'results',
          },
          {
            target: 'question',
            actions: assign({
              currentIndex: ({ context }) => context.currentIndex + 1,
            }),
          },
        ],
      },
    },

La condition de fin réside désormais à un seul endroit au lieu d’être re-déduite dans chaque gestionnaire qui touche à l’index.

Qu’apporte la machine à états ?

Les lignes illégales du tableau ci-dessus sont désormais inatteignables, et pas seulement improbables : aucune séquence d’événements ne place la machine dans l’état « réponse pendant le chargement », car aucune transition n’y mène. Le second bénéfice concerne le changement. Ajouter une étape de révision à la version booléenne implique un quatrième drapeau et seize combinaisons à analyser ; l’ajouter à la machine implique un nouvel état review, une transition vers celui-ci depuis results, et une transition de sortie. Le composant gagne une branche if, et chaque état existant continue de se comporter exactement comme avant.

Où appliquer ce motif ensuite

Le motif se décline à des échelles bien plus petites que ne le suggèrent la plupart des tutoriels : tout écran où vous vous surprenez à écrire if (isX && !isY) est candidat à quatre ou cinq états nommés. Reprenez le fichier de machine de cet article, remplacez-en les états et les événements par ceux de votre propre domaine, et faites en sorte que le type de bug figurant dans ce tableau de combinaisons devienne quelque chose que votre composant ne peut tout simplement pas exprimer.

FAQ

Quelle est la différence entre une machine XState et useReducer ?

Les deux centralisent les transitions dans une seule fonction, mais un reducer accepte n'importe quelle action dans n'importe quel état, si bien que les combinaisons illégales restent exprimables. Une machine à états ne répond qu'aux événements listés sous son état courant : un événement ANSWER reçu dans l'état results est ignoré par construction. Un reducer laisse également l'ensemble des états possibles implicite dans ses données, alors qu'une machine nomme explicitement chaque état.

XState v5 exige-t-il TypeScript ?

Non. XState v5 fonctionne en JavaScript pur, et chaque extrait de code de cet article fonctionne sans types. Si vous utilisez TypeScript, la version minimale prise en charge est la 5.0. Avec TypeScript, l'API setup() permet de déclarer les types du contexte et des événements afin que les transitions et les appels à assign soient vérifiés à la compilation.

Le code des tutoriels XState v4 fonctionne-t-il en v5 ?

Non, plusieurs API centrales ont été renommées lors de la migration de la v4 vers la v5. Machine() est devenu createMachine(), interpret() est devenu createActor(), la propriété cond des transitions est devenue guard, et les services invoqués sont devenus des acteurs. Les signatures des callbacks ont également changé : les actions et les gardes reçoivent désormais un objet unique contenant le contexte et l'événement au lieu d'arguments séparés. Si un extrait utilise cond ou interpret, il s'agit de code v4 et il ne fonctionnera pas en v5.

Deux composants React peuvent-ils partager l'état d'une même machine XState ?

Pas en appelant useMachine deux fois : chaque appel crée un acteur indépendant avec son propre instantané, si bien que deux composants utilisant useMachine(quizMachine) détiennent des états distincts et non synchronisés. Pour partager une seule machine en cours d'exécution, créez l'acteur une seule fois et distribuez-le, soit avec createActorContext du paquet xstate react, soit en transmettant l'actorRef renvoyé par useMachine sous forme de prop et en le lisant avec useSelector.

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.