Aller au contenu principal

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ù useState devient difficile à maîtriser ;
  • écrire un reducer : une fonction pure qui calcule le nouvel état ;
  • décrire les actions possibles avec une union TypeScript ;
  • utiliser useReducer et dispatch dans 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...

  1. jouer une partie de niveau 1 jusqu'au bout et lire le texte du dernier bouton
  2. jouer le niveau 3 et lire le message affiché
  3. dire qui possède l'état ecran et comment Partie pré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) :

src/logic/partieReducer.ts
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.

src/logic/partieReducer.ts
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 :

  1. c'est une fonction pure : même état et même action, même résultat ;
  2. il ne modifie jamais l'état reçu : il renvoie un nouvel objet (avec le spread ...etat) ;
  3. il ne fait rien d'autre que calculer : pas d'appel réseau, pas de console.log, pas de hasard ;
  4. 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 avec useState) ;
  • 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 ?​

SituationChoix
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'autreuseReducer
Bonne pratique - Un reducer se teste sans React

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​


Que renvoie useReducer(partieReducer, etatInitial) ?


Que doit renvoyer un reducer quand l'action n'a pas de sens dans l'état actuel ?


Laquelle de ces opérations est interdite dans un reducer ?


Comment envoie-t-on l'action SUIVANTE au reducer ?


Pourquoi le double clic sur une réponse n'ajoute-t-il qu'un point ?


Dans quel cas choisir plutôt useState ?



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.


Bonne pratique - Des types pour les 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.


Bonne pratique - Ne jamais écrire etat.score++

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.


Bonne pratique - Le switch exhaustif

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.


Bonne pratique - Importer useReducer depuis react

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.


Bonne pratique - Le composant décrit, le reducer décide

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​

NotionRésumé
useReducerconst [etat, dispatch] = useReducer(reducer, etatInitial) : un état piloté par des actions.
ActionUn objet qui décrit ce qui s'est passé : { type: "CHOISIR", ... }, typé par une union.
ReducerFonction pure (etat, action) => nouvelEtat : ne modifie rien, ne fait rien d'autre que calculer.
Action hors séquenceLe reducer renvoie l'état inchangé : protection naturelle contre le double clic.
Machine à étatsUn champ statut décrit l'étape, et seules certaines actions sont valides à chaque étape.
ChoixuseState 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.