useReducer : une machine à états pour la partie
Quand plusieurs états évoluent ensemble, une seule fonction décide de tous les changements.
Objectifs de la séance
- reconnaître le moment où
useStatedevient difficile à maîtriser ; - écrire un reducer : une fonction pure qui calcule le nouvel état ;
- décrire les actions possibles avec une union TypeScript ;
- utiliser
useReduceretdispatchdans un composant ; - rendre les états impossibles impossibles, et ignorer les actions hors séquence.
Petite vérification
Avant de commencer, vérifiez si vous savez...
- jouer une partie de niveau 1 jusqu'au bout et lire le texte du dernier bouton
- jouer le niveau 3 et lire le message affiché
- dire qui possède l'état
ecranet commentPartieprévient ce composant de la fin de la partie
📌 Réponse attendue
Notions théoriques
Le problème : trois états qui évoluent ensemble
Dans Partie, indice, score et choix changent presque toujours ensemble. Une mauvaise combinaison est possible : par exemple, passer à la question suivante sans avoir effacé le choix.
setIndice((ancien) => ancien + 1);
// oubli : setChoix(null); → la nouvelle question s'affiche avec une réponse déjà « choisie »
Plus le composant grandit, plus les setXxx se multiplient et se dispersent dans plusieurs fonctions.
L'idée : une seule fonction décide
Avec useReducer, le composant ne modifie plus l'état lui-même. Il décrit ce qui s'est passé (une action), et une fonction écrite à part, le reducer, calcule le nouvel état.
clic sur une réponse → dispatch({ type: "CHOISIR", ... }) → reducer(état, action) → nouvel état
Tous les changements possibles sont rassemblés dans une seule fonction : c'est facile à relire et à tester.
L'état et les actions
On décrit d'abord l'état (avec un statut qui indique l'étape de la partie) et les actions possibles (une union, comme pour Ecran) :
export type EtatPartie = {
statut: "question" | "correction";
indice: number;
score: number;
choix: number | null;
};
export type ActionPartie =
| { type: "CHOISIR"; reponseId: number; estCorrecte: boolean }
| { type: "SUIVANTE" };
export const etatInitial: EtatPartie = {
statut: "question",
indice: 0,
score: 0,
choix: null,
};
Le statut forme une petite machine à états :
CHOISIR SUIVANTE
question ───────▶ correction ───────────▶ question (indice + 1)
Le reducer
Un reducer reçoit l'état actuel et une action, et renvoie le nouvel état.
export function partieReducer(etat: EtatPartie, action: ActionPartie): EtatPartie {
switch (action.type) {
case "CHOISIR":
if (etat.statut !== "question") {
return etat; // action hors séquence (double clic) : on ignore
}
return {
...etat,
statut: "correction",
choix: action.reponseId,
score: action.estCorrecte ? etat.score + 1 : etat.score,
};
case "SUIVANTE":
if (etat.statut !== "correction") {
return etat;
}
return { ...etat, statut: "question", indice: etat.indice + 1, choix: null };
}
}
Les règles d'un reducer :
- c'est une fonction pure : même état et même action, même résultat ;
- il ne modifie jamais l'état reçu : il renvoie un nouvel objet (avec le spread
...etat) ; - il ne fait rien d'autre que calculer : pas d'appel réseau, pas de
console.log, pas de hasard ; - si l'action n'a pas de sens dans l'état actuel, il renvoie l'état inchangé (
return etat).
La quatrième règle protège de façon élégante contre le double clic : le second CHOISIR arrive quand le statut est déjà "correction", et il est ignoré, sans aucun if dans le composant.
Utiliser useReducer dans le composant
import { useReducer } from "react";
const [etat, dispatch] = useReducer(partieReducer, etatInitial);
etat: l'état actuel (comme avecuseState) ;dispatch: la fonction qui envoie une action au reducer ;- les deux arguments : le reducer et l'état initial.
<button onClick={() => dispatch({ type: "CHOISIR", reponseId: 13, estCorrecte: true })}>
useState ou useReducer ?
| Situation | Choix |
|---|---|
| Une valeur simple, indépendante (un champ de formulaire, un booléen) | useState |
| Plusieurs valeurs qui évoluent ensemble, des règles de passage d'un état à l'autre | useReducer |
Comme le reducer est une simple fonction pure, on la teste en une ligne : partieReducer(etatInitial, { type: "SUIVANTE" }) doit renvoyer etatInitial (action hors séquence). Vous écrirez ces tests dans le tutoriel 5.
Test de mémorisation/compréhension
TP pour réfléchir et résoudre des problèmes
Votre mission : remplacer les trois useState de Partie par un reducer.
Étape 1 — Les types
Créez src/logic/partieReducer.ts. Décrivez l'état et les deux actions.
Avec l'union ActionPartie, TypeScript refuse dispatch({ type: "CHOISR" }) (faute de frappe) et vérifie que chaque action contient les données requises.
Étape 2 — Le cas CHOISIR
Écrivez la première branche du reducer : ignorer l'action hors séquence, sinon passer en correction.
etat.score++ modifierait l'objet reçu. Le spread ...etat crée un nouvel objet ; seuls les champs listés après changent.
Étape 3 — Le cas SUIVANTE
Écrivez la seconde branche : seulement depuis la correction, avancer l'indice, effacer le choix.
Si vous ajoutez plus tard une action RECOMMENCER à l'union sans la gérer dans le switch, TypeScript signale que la fonction ne renvoie pas toujours un état : le compilateur vous rappelle le cas oublié.
Étape 4 — Brancher le reducer dans Partie
Remplacez les trois useState par useReducer.
import { useReducer } from "react"; en haut du fichier. Supprimez l'import de useState, devenu inutile (npm run lint vous le signale).
Étape 5 — Envoyer les actions
Écrivez choisir et suivante : le composant ne modifie plus rien, il envoie des actions.
Partie ne contient plus aucune règle de passage d'un état à l'autre : elle affiche l'état et envoie des actions. Toutes les règles sont dans partieReducer, relisibles et testables d'un seul coup d'œil.
📌 Une solution
Ce qu'il faut retenir
| Notion | Résumé |
|---|---|
useReducer | const [etat, dispatch] = useReducer(reducer, etatInitial) : un état piloté par des actions. |
| Action | Un objet qui décrit ce qui s'est passé : { type: "CHOISIR", ... }, typé par une union. |
| Reducer | Fonction pure (etat, action) => nouvelEtat : ne modifie rien, ne fait rien d'autre que calculer. |
| Action hors séquence | Le reducer renvoie l'état inchangé : protection naturelle contre le double clic. |
| Machine à états | Un champ statut décrit l'étape, et seules certaines actions sont valides à chaque étape. |
| Choix | useState pour une valeur simple, useReducer pour des états qui évoluent ensemble. |
Aperçu de la prochaine séance
Vous maîtrisez maintenant l'état. Dans le tutoriel 3, vous allez découvrir les effets avec useEffect : un chronomètre qui s'arrête proprement, puis le chargement des questions avec fetch, sans bug de réponses dans le désordre.