Aller au contenu principal

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 useReducer et dispatch dans l'écran, et tester le reducer avec npm test.

Petite vérification​

Avant de commencer, vérifiez si vous savez...

  1. 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
  2. citer le nom de la fonction de src/logic/score.ts qui empêche de compter deux fois la même question
  3. exécuter npm test et 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
StatutSignificationActions acceptées
"question"une question attend la réponse du joueurREPONDRE
"correction"le joueur a répondu, la correction est affichéeQUESTION_SUIVANTE
"terminee"toutes les questions ont été jouéesaucune (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 (action RECOMMENCER), elles sont tirées avant et transmises dans l'action ;
  • pas de fetch, pas de setTimeout : 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émentRôle
partieReducerla fonction qui calcule le nouvel état
undefinedl'argument transmis à la fonction initiale (inutile ici)
() => creerEtatInitial(...)fonction initiale : appelée une seule fois, au premier affichage (comme l'initialiseur paresseux de useState)
etatl'état actuel, à lire
dispatchla 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 ou useReducer ?

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).


Bonne pratique - Interdire les états impossibles

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.


Bonne pratique - Des noms d'action au passé ou à l'événement

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).


Bonne pratique - Prévoir le cas vide

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.


Bonne pratique - Valider ce qui entre dans le reducer

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 »).


Bonne pratique - Toujours un default

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).


Bonne pratique - Un reducer pur est très facile à tester

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.


Bonne pratique - Un seul état, un seul dispatch

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".


Bonne pratique - L'écran déclare, le reducer décide

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.


Bonne pratique - Le hasard hors du reducer

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​


Bonne pratique - Un commit = un sujet

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​


Quelle est la forme générale d'un reducer ?


Que fait l'appel dispatch({ type: "QUESTION_SUIVANTE" }) ?


Que doit renvoyer le reducer si l'action REPONDRE arrive alors que le statut vaut "correction" ?


Pourquoi le tirage au hasard des nouvelles questions est-il fait dans rejouer et non dans le reducer ?


Combien de statuts comporte la machine à états de la partie dans cette séance ?


Dans quel cas préfère-t-on useReducer à useState ?



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​

NotionRésumé
Machine à étatsLa partie est dans un seul statut à la fois (question, correction, terminee) ; seules certaines transitions sont autorisées.
ActionObjet { type: ... } qui décrit ce qui s'est passé ; typée par une union.
ReducerFonction pure (état, action) => nouvel état ; ignore les actions interdites en renvoyant l'état reçu.
useReducerFournit [etat, dispatch] ; la fonction initiale n'est appelée qu'une fois.
HasardJamais dans le reducer : il est tiré avant et transmis dans l'action.
Double-clicDeuxiè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.