Calculer le score
Calculer le score du joueur sans jamais le compter deux fois : un état minimal et des valeurs dérivées.
Objectifs de la séance
- distinguer ce qui doit être stocké dans l'état de ce qui doit être calculé (valeur dérivée) ;
- utiliser la mise à jour fonctionnelle
setX((ancien) => ...); - ne jamais modifier un tableau existant : créer un nouveau tableau (
[...liste, element]) ; - écrire une fonction idempotente (la rejouer ne change pas le résultat) qui empêche de compter deux fois une réponse ;
- comprendre le bug classique du « score compté deux fois ».
Petite vérification
Avant de commencer, vérifiez si vous savez...
- jouer une partie complète dans le navigateur et lire le texte du bouton affiché sous l'explication de la dernière question
- cliquer sur « Rejouer » à la fin de la partie et dire si les questions sont les mêmes que dans la partie précédente
- exécuter
npm testet lire le nombre de tests réussis
📌 Réponse attendue
Notions théoriques
Que faut-il stocker dans l'état ?
Pour afficher le score, la première idée est un second état :
const [score, setScore] = useState(0);
// ...et, quand le joueur répond correctement :
setScore(score + 1);
Cette solution semble correcte, mais elle cache plusieurs pièges.
Piège 1 : la valeur « figée » du rendu. Dans un gestionnaire, score garde la valeur du rendu en cours.
Ces deux lignes consécutives ne font gagner qu'un point, pas deux :
setScore(score + 1); // score vaut 0 : on demande 0 + 1
setScore(score + 1); // score vaut toujours 0 : on demande encore 0 + 1
Piège 2 : la mise à jour fonctionnelle. Pour partir de la valeur la plus récente, React accepte une
fonction : setScore((ancien) => ancien + 1). Les deux lignes ci-dessus donneraient alors bien +2.
C'est la bonne habitude dès que le nouvel état dépend de l'ancien.
Piège 3 : le double-clic. Un joueur appuie deux fois très vite sur la bonne réponse. Le gestionnaire est appelé deux fois. Avec un score stocké et une mise à jour fonctionnelle, le point est ajouté deux fois : une seule question rapporte 2 points. Le score est faux, et c'est exactement le type de triche involontaire qu'un quiz de cybersécurité ne doit pas permettre.
La solution : stocker les réponses, calculer le score
Au lieu de stocker le score, on stocke ce qui s'est passé : la liste des réponses données (une par question). Le score se déduit de cette liste :
// État : la liste des réponses données
const [reponsesDonnees, setReponsesDonnees] = useState<ReponseDonnee[]>([]);
// Valeur DÉRIVÉE : recalculée à chaque affichage
const score = calculerScore(reponsesDonnees);
Avantages :
- le score ne peut pas être incohérent avec les réponses (il est recalculé à chaque fois) ;
- on connaît aussi la réponse donnée à chaque question (utile pour la correction) ;
- la règle « une seule réponse par question » devient facile à imposer, dans une fonction pure et testable.
Une fonction idempotente
ajouterReponseDonnee ajoute une réponse sauf si la question a déjà reçu une réponse : dans ce cas,
elle renvoie la liste inchangée. On dit qu'elle est idempotente : l'appeler deux fois avec la même
entrée donne le même résultat que de l'appeler une fois. Un double-clic ne peut donc plus compter deux fois.
export function ajouterReponseDonnee(
reponsesDonnees: ReponseDonnee[],
nouvelle: ReponseDonnee,
): ReponseDonnee[] {
const dejaRepondu = reponsesDonnees.some(
(r) => r.questionId === nouvelle.questionId,
);
if (dejaRepondu) {
return reponsesDonnees; // on renvoie la liste inchangée
}
// Nouvelle liste (on ne modifie jamais l'ancienne avec push)
return [...reponsesDonnees, nouvelle];
}
Ne jamais modifier un tableau : en créer un nouveau
Remarquez la dernière ligne : [...reponsesDonnees, nouvelle] crée un nouveau tableau qui contient
les anciens éléments suivis du nouveau. On n'écrit jamais reponsesDonnees.push(nouvelle) : push
modifie le tableau existant, React ne verrait pas de changement (c'est le même tableau) et n'afficherait
pas l'écran à jour. C'est la règle d'immutabilité de l'état.
| À éviter (modifie l'existant) | À utiliser (crée un nouveau) |
|---|---|
liste.push(x) | [...liste, x] |
liste.splice(i, 1) | liste.filter((_, index) => index !== i) |
objet.score = 3 | { ...objet, score: 3 } |
La mise à jour fonctionnelle dans le gestionnaire
setReponsesDonnees((anciennes) =>
ajouterReponseDonnee(anciennes, { questionId, reponseId, estCorrecte }),
);
React appelle la fonction avec la dernière liste connue, même si deux appuis se suivent très vite.
Combinée à l'idempotence de ajouterReponseDonnee, la deuxième réponse à la même question est
ignorée : deux verrous valent mieux qu'un seul.
TP pour réfléchir et résoudre des problèmes
Votre mission : afficher le score pendant la partie et à la fin, sans jamais le stocker.
Étape 1 — Décrire une réponse donnée
Dans src/types/quiz.ts, ajoutez à la fin du fichier un type qui décrit une réponse donnée par le
joueur pendant la partie.
ReponseDonnee (ce que le joueur a répondu) n'est pas Reponse (une proposition de la banque de
questions). Deux notions différentes méritent deux types différents.
Étape 2 — Ajouter une réponse (sans doublon)
Créez src/logic/score.ts et écrivez la fonction ajouterReponseDonnee. Elle reçoit la liste actuelle et
la nouvelle réponse, et renvoie une nouvelle liste.
Écrire reponsesDonnees.push(nouvelle) modifierait le tableau reçu en paramètre. Créer un nouveau
tableau garde l'ancien intact : c'est ce qui rend la fonction pure et l'état fiable.
Étape 3 — Calculer le score
Dans le même fichier, ajoutez calculerScore : le score est le nombre de réponses correctes.
Ajoutez aussi trouverReponseDonnee (voir « Une solution ») : elle retrouve la réponse donnée à une question,
ou null si le joueur n'a pas encore répondu.
calculerScore est appelée à chaque affichage : le score affiché correspond toujours aux réponses
enregistrées. Aucune chance d'oublier de le mettre à jour.
Étape 4 — Tester le score
Créez tests/score.test.ts. Le test le plus important est celui du double-clic : une deuxième réponse à
la même question ne doit rien changer.
Lancez npm test : tests 20 et pass 20 (5 nouveaux tests).
Le double-clic est un bug que l'on sait reproduire. Écrire le test avant de passer à l'écran fixe le comportement attendu pour toujours : si quelqu'un modifie un jour la fonction, le test échouera.
Étape 5 — Remplacer l'état « réponse choisie » par la liste des réponses
Dans src/app/index.tsx, supprimez l'état reponseChoisieId et remplacez-le par la liste des réponses
données. Le score devient une valeur calculée.
Chaque useState supplémentaire est une information de plus à garder cohérente. Dès qu'une valeur
peut être calculée à partir d'autres, calculez-la.
Étape 6 — Enregistrer une réponse avec une mise à jour fonctionnelle
Réécrivez choisirReponse : elle reçoit maintenant l'identifiant de la question, celui de la réponse et un
booléen « est correcte », et délègue à ajouterReponseDonnee.
N'oubliez pas de modifier passerALaSuite (qui n'a plus à effacer la réponse choisie) et rejouer (qui
remet reponsesDonnees à []).
La forme setX((ancien) => nouveau) est la plus sûre quand le nouvel état dépend de l'ancien. Prenez
l'habitude de l'utiliser, même si la forme simple « marche » dans vos essais.
Étape 7 — Retrouver la réponse de la question courante
Pour colorer les boutons, l'écran a besoin de la réponse donnée à la question affichée (s'il y en a une).
reponseDonnee?.reponseId évite une erreur quand reponseDonnee vaut null : l'expression renvoie alors
undefined, et ?? null la remplace par null. C'est la façon concise de traiter « il n'y a pas encore de valeur ».
Étape 8 — Afficher le score
Affichez le score dans le badge de l'en-tête, et sur l'écran de fin sous la forme « Score : 4 / 5 ».
Jouez plusieurs parties et vérifiez que le score augmente seulement avec les bonnes réponses. Essayez de double-cliquer très vite sur une bonne réponse : le score ne doit gagner qu'un point.
Le double-clic rapide, la dernière question, « Rejouer » : testez volontairement les situations que l'utilisateur provoquera tôt ou tard.
Étape 9 — Enregistrer votre travail
« Calculer le score à partir des réponses données » décrit le résultat du commit. Évitez « modif » ou « test » : dans six mois, personne (vous compris) ne saurait ce qu'ils contiennent.
📌 Une solution
Test de mémorisation/compréhension
Exercices complémentaires
Exercice A : le pourcentage de réussite
Sur l'écran de fin, affichez le pourcentage de bonnes réponses (par exemple « 80 % »). Calculez-le directement
dans le JSX ou dans une constante : Math.round((score / questions.length) * 100). Avez-vous eu besoin d'un useState ?
Exercice B : tester trouverReponseDonnee
Dans tests/score.test.ts, ajoutez un test qui vérifie que trouverReponseDonnee renvoie bien la réponse
donnée lorsqu'elle existe (utilisez une liste contenant une réponse pour la question 1).
Ce qu'il faut retenir
| Notion | Résumé |
|---|---|
| État minimal | On stocke ce qui est nécessaire (les réponses données), pas ce qui peut être déduit (le score). |
| Valeur dérivée | const score = calculerScore(reponsesDonnees) : recalculée à chaque affichage, jamais périmée. |
| Mise à jour fonctionnelle | setX((ancien) => nouveau) part de la dernière valeur connue. |
| Immutabilité | Ne jamais modifier un tableau ou un objet de l'état : en créer un nouveau ([...liste, x]). |
| Idempotence | ajouterReponseDonnee ignore une deuxième réponse à la même question : un double-clic ne compte pas deux fois. |
Aperçu de la prochaine séance
L'état de la partie est maintenant réparti entre plusieurs variables : les questions, l'index, les réponses
données. Les règles du jeu (« on ne peut répondre qu'une fois », « la question suivante n'existe qu'après la
correction »...) sont dispersées dans plusieurs fonctions. Dans la prochaine séance, vous allez les rassembler
dans une machine à états pilotée par le hook useReducer.