Une machine à états avec useReducer
Rassembler les règles de la partie dans un reducer : un seul endroit, des transitions contrôlées, impossible d'arriver dans un état incohérent.
Objectifs de la séance
- dessiner le diagramme d'états d'une partie de quiz ;
- écrire des actions typées (union de types) ;
- écrire un reducer : une fonction pure
(état, action) => nouvel état; - ignorer les actions qui arrivent au mauvais moment ;
- utiliser
useReduceretdispatchdans l'écran, et tester le reducer avecnpm test.
Petite vérification
Avant de commencer, vérifiez si vous savez...
- jouer une partie dans le navigateur et double-cliquer très vite sur la bonne réponse d'une question : lire le score affiché dans le badge
- citer le nom de la fonction de
src/logic/score.tsqui empêche de compter deux fois la même question - exécuter
npm testet lire le nombre de tests réussis
📌 Réponse attendue
Notions théoriques
Pourquoi un reducer ?
L'écran actuel contient plusieurs useState (questions, index, réponses données) et plusieurs fonctions qui
les modifient (choisirReponse, passerALaSuite, rejouer). Les règles du jeu sont dispersées :
- « on ne peut répondre qu'une fois à une question » ;
- « on ne peut passer à la suite qu'après avoir répondu » ;
- « après la dernière question, la partie est terminée ».
Rien n'empêche d'oublier une règle dans un coin du code. Un reducer rassemble toutes ces règles dans une seule fonction, que l'on peut tester sans lancer l'application.
Le diagramme d'états
Une partie ne peut se trouver que dans trois états (on parle de statut). Les flèches sont les seules transitions autorisées :
question --REPONDRE--> correction --QUESTION_SUIVANTE--> question
\--(dernière question)--> terminee
| Statut | Signification | Actions acceptées |
|---|---|---|
"question" | une question attend la réponse du joueur | REPONDRE |
"correction" | le joueur a répondu, la correction est affichée | QUESTION_SUIVANTE |
"terminee" | toutes les questions ont été jouées | aucune (on affiche le résultat) |
Cette représentation s'appelle une machine à états : à tout instant, la partie est dans un et un seul statut, et seules certaines actions font passer d'un statut à un autre.
Les actions
Une action est un petit objet qui décrit ce qui vient de se passer. Son champ type identifie
l'action. En TypeScript, on liste toutes les actions possibles avec une union :
export type ActionPartie =
| { type: "REPONDRE"; reponseId: number }
| { type: "QUESTION_SUIVANTE" }
| { type: "RECOMMENCER"; questions: Question[] };
Selon le type, l'action porte des informations complémentaires (reponseId pour REPONDRE,
questions pour RECOMMENCER). TypeScript sait, dans chaque case, quels champs sont disponibles.
Le reducer : une fonction pure
Un reducer reçoit l'état actuel et une action, et renvoie le nouvel état :
(etat, action) => nouvelEtat
C'est une fonction pure (tutoriel 2) : mêmes paramètres, même résultat, et aucune modification de l'état
reçu (on renvoie un nouvel objet avec { ...etat, ... }). Conséquences :
- pas de hasard (
Math.random) dans un reducer : il ne serait plus prévisible, donc plus testable. Quand il faut des questions tirées au hasard (actionRECOMMENCER), elles sont tirées avant et transmises dans l'action ; - pas de
fetch, pas desetTimeout: uniquement un calcul.
Ignorer les actions interdites
Le point clé de la machine à états : une action qui arrive au mauvais moment est ignorée, en renvoyant exactement l'état reçu.
case "REPONDRE": {
// On ne peut répondre que lorsqu'une question attend une réponse
if (etat.statut !== "question") {
return etat;
}
// ...
}
Un double-clic envoie deux actions REPONDRE. La première fait passer le statut à "correction" ; la
seconde trouve un statut différent de "question" et ne change rien. C'est la deuxième protection contre
le double-clic (après le bouton désactivé de la séance 1) : elle est garantie par la logique, pas par l'interface.
useReducer et dispatch
const [etat, dispatch] = useReducer(partieReducer, undefined, () =>
creerEtatInitial(nouvellesQuestions()),
);
| Élément | Rôle |
|---|---|
partieReducer | la fonction qui calcule le nouvel état |
undefined | l'argument transmis à la fonction initiale (inutile ici) |
() => creerEtatInitial(...) | fonction initiale : appelée une seule fois, au premier affichage (comme l'initialiseur paresseux de useState) |
etat | l'état actuel, à lire |
dispatch | la fonction qui envoie une action au reducer : dispatch({ type: "REPONDRE", reponseId: 13 }) |
Côté écran, plus aucune règle : on se contente de déclarer ce qui s'est passé (dispatch) et
d'afficher l'état renvoyé.
useState convient à une valeur simple et indépendante. useReducer est préférable quand plusieurs
informations changent ensemble et que des règles relient ces changements, comme ici.
TP pour réfléchir et résoudre des problèmes
Votre mission : remplacer les trois useState de l'écran par un reducer, testé, qui pilote la partie.
Étape 1 — Décrire l'état de la partie
Créez src/logic/partieReducer.ts. Commencez par les types : le statut (l'une des trois valeurs du
diagramme) et l'état complet.
Ajoutez en tête du fichier les importations : import type { Question, ReponseDonnee } from "@/types/quiz";
et import { ajouterReponseDonnee } from "./score.ts"; (chemin relatif avec l'extension, car ce fichier est
exécuté par node --test).
Avec une union de trois chaînes, un statut "fini" ou "Question" est refusé par TypeScript. Plus vos types sont
précis, moins il reste d'erreurs possibles à l'exécution.
Étape 2 — Décrire les actions
Ajoutez le type des actions. Chacune est un objet avec un champ type.
REPONDRE, QUESTION_SUIVANTE, RECOMMENCER disent ce qui se passe dans l'application, pas comment
l'état doit changer. C'est le reducer qui décide des conséquences.
Étape 3 — L'état initial
La fonction creerEtatInitial construit l'état de départ à partir de la liste de questions. Si la liste est
vide, la partie est terminée tout de suite (pas de question à poser).
Une liste vide n'arrivera presque jamais... jusqu'au jour où un filtre ne renverra aucune question. Le
code qui gère ce cas dès maintenant évite un plantage (questions[0] vaudrait undefined).
Étape 4 — Le reducer : répondre à une question
Écrivez la fonction partieReducer et son premier cas, REPONDRE. C'est ici que le double-clic est neutralisé.
Remarquez les deux autres protections : un identifiant de réponse inconnu est lui aussi ignoré
(reponse === undefined), et le nouvel état est construit avec { ...etat, ... } sans modifier l'ancien.
Le reducer ne fait confiance à rien : statut incorrect, identifiant inconnu... il renvoie l'état d'origine. Ce réflexe (ne jamais faire confiance à une entrée) sera central dans les tutoriels sur la sécurité.
Étape 5 — Le reducer : passer à la suite
Le cas QUESTION_SUIVANTE n'est accepté qu'après la correction. Après la dernière question, le statut
devient "terminee".
Terminez le reducer avec le cas RECOMMENCER (qui reconstruit un état initial) et un default qui renvoie
l'état inchangé (voir « Une solution »).
Le default d'un switch renvoie l'état tel quel : si une action inconnue arrive un jour, l'application
continue de fonctionner au lieu de planter.
Étape 6 — Tester le reducer
Créez tests/partieReducer.test.ts. Chaque règle du diagramme devient un test. Le plus important simule le
double-clic : la deuxième réponse est ignorée et la première est conservée.
Lancez npm test : tests 27 et pass 27 (7 nouveaux tests).
Aucune interface, aucun clic : on appelle la fonction avec un état et une action, et on vérifie le résultat. Les règles les plus importantes de l'application sont ainsi vérifiées en une fraction de seconde.
Étape 7 — Utiliser useReducer dans l'écran
Dans src/app/index.tsx, supprimez les useState et leurs fonctions de modification, et remplacez-les par
useReducer. Importez useReducer depuis react, et creerEtatInitial et partieReducer depuis @/logic/partieReducer.
L'écran n'a plus qu'une source de vérité : etat. Le score, la question courante, estEnCorrection... se
calculent à partir de lui.
Étape 8 — Envoyer des actions depuis les boutons
Le bouton d'une réponse n'appelle plus une fonction maison : il envoie l'action REPONDRE. Le bouton
« Question suivante » envoie QUESTION_SUIVANTE.
Remarquez aussi que desactive={estEnCorrection} : le bouton est désactivé dès que le statut vaut "correction".
Le gestionnaire ne contient plus de condition « si déjà répondu ». L'interface désactive le bouton pour l'utilisateur ; la vraie protection est dans le reducer, même si l'interface se trompe.
Étape 9 — Recommencer sans hasard dans le reducer
Pour rejouer, l'écran tire de nouvelles questions lui-même, puis les transmet dans l'action RECOMMENCER.
Le tirage au hasard est effectué dans le gestionnaire (rejouer), jamais dans le reducer : c'est la condition
pour que partieReducer reste pur, prévisible et testable.
Étape 10 — Enregistrer votre travail
Cette séance est un changement d'architecture : tout est dans un seul commit, qui raconte une seule
histoire. Si l'on veut annuler la machine à états, un seul git revert suffit.
📌 Une solution
Test de mémorisation/compréhension
Exercices complémentaires
Exercice A : un test de plus
Dans tests/partieReducer.test.ts, ajoutez un test qui vérifie qu'après REPONDRE puis QUESTION_SUIVANTE
sur une partie de deux questions, l'état a un indexQuestion égal à 1 et un statut "question".
Exercice B : les transitions interdites
Sur papier, listez toutes les actions qui sont ignorées par le reducer selon le statut (par exemple :
QUESTION_SUIVANTE au statut "question"). Retrouvez chacune de ces règles dans le code de partieReducer.
Ce qu'il faut retenir
| Notion | Résumé |
|---|---|
| Machine à états | La partie est dans un seul statut à la fois (question, correction, terminee) ; seules certaines transitions sont autorisées. |
| Action | Objet { type: ... } qui décrit ce qui s'est passé ; typée par une union. |
| Reducer | Fonction pure (état, action) => nouvel état ; ignore les actions interdites en renvoyant l'état reçu. |
useReducer | Fournit [etat, dispatch] ; la fonction initiale n'est appelée qu'une fois. |
| Hasard | Jamais dans le reducer : il est tiré avant et transmis dans l'action. |
| Double-clic | Deuxième protection : le reducer ignore un second REPONDRE. |
Aperçu de la prochaine séance
Votre partie est solide, mais elle ne mesure pas le temps. Dans la prochaine séance, vous allez ajouter
un chronomètre avec useEffect : l'occasion de découvrir les effets, la fonction de nettoyage et
le piège de la minuterie qui continue de tourner quand plus personne ne la regarde.