Réagir au clic avec useState
Faire réagir l'écran quand le joueur choisit une réponse : bonne réponse en vert, mauvaise en rouge, explication affichée.
Objectifs de la séance
- comprendre pourquoi une variable ordinaire ne suffit pas pour faire changer l'écran ;
- mémoriser le choix du joueur avec le hook
useState; - représenter « le joueur n'a pas encore répondu » avec la valeur
null; - écrire et tester une fonction pure qui calcule l'apparence d'un bouton ;
- empêcher le double-clic sur une réponse (première protection).
Petite vérification
Avant de commencer, vérifiez si vous savez...
- lancer
npx expo start, ouvrir l'application dans le navigateur (touchew) et lire l'énoncé de la question affichée à l'écran, ainsi que l'illustration qui l'accompagne - cliquer sur une réponse, ouvrir la console du navigateur (touche
F12, onglet Console) et lire le message qui s'y affiche - exécuter
npm testdans un second terminal et lire le nombre de tests réussis
📌 Réponse attendue
TP pour réfléchir et résoudre des problèmes
Pour l'instant, cliquer sur une réponse écrit seulement un message dans la console. Votre mission :
quand le joueur clique, colorer la bonne réponse en vert, sa propre réponse en rouge si elle est
fausse, griser les autres boutons et afficher l'explication de la question. Gardez
npx expo start lancé : chaque enregistrement met l'écran à jour.
Étape 1 — Créer la branche du tutoriel
À partir de ce tutoriel, on ne travaille plus directement sur la branche main : chaque tutoriel a sa
propre branche. Celle de ce tutoriel s'appelle feature/etat.
Si la Pull Request du tutoriel précédent n'est pas encore fusionnée sur GitHub, fusionnez-la d'abord (comme vous l'avez fait à la séance sur les branches). Sinon, votre nouvelle branche ne contiendra pas tout votre travail.
Dans le terminal, à la racine du projet quiz-cyber-expo, retournez sur main, récupérez la
dernière version, puis créez la branche et basculez dessus :
Travailler sur une branche permet de tout essayer sans risque : main reste toujours une version
qui fonctionne. À la fin du tutoriel, une Pull Request permettra de relire et de fusionner le tout.
Étape 2 — Calculer l'apparence d'un bouton (fonction pure)
Créez le fichier src/logic/etatBouton.ts. La fonction calculerEtatBouton répond à la question :
« dans quel état doit être affiché ce bouton ? » en fonction de trois informations : le bouton
concerné, la réponse choisie par le joueur (ou null) et la bonne réponse.
Recopiez aussi, au-dessus de la fonction, le type et les commentaires :
// Calcule l'apparence d'un bouton de réponse.
// - "normal" : la question n'est pas encore corrigée
// - "bonne" : c'est la bonne réponse (affichée en vert)
// - "mauvaise" : c'est la réponse choisie, et elle est fausse (en rouge)
// - "attenue" : une autre réponse, sans importance (grisée)
export type EtatBouton = "normal" | "bonne" | "mauvaise" | "attenue";
La fonction calculerEtatBouton ne dépend que de ses paramètres et ne modifie rien : c'est une
fonction pure (tutoriel 2). Ici, elle sort la « règle du jeu » du composant, ce qui la rend
facile à tester, à relire et à réutiliser.
Étape 3 — Tester la fonction avec npm test
Créez le fichier tests/etatBouton.test.ts avec les quatre cas du jeu : avant toute réponse, bonne
réponse, mauvaise réponse choisie, réponse sans importance.
Le début du fichier est identique à celui de la séance précédente sur les tests (première ligne
/// <reference types="node" />, importations de node:assert/strict et node:test, puis de
../src/logic/etatBouton.ts avec l'extension). Lancez ensuite :
npm test
Le résumé doit afficher tests 12 et pass 12 (les 8 tests existants plus vos 4 nouveaux tests).
Un test sur une fonction pure s'exécute en quelques millisecondes, sans lancer l'application ni cliquer. Si une règle est fausse, vous le voyez immédiatement et vous savez dans quel fichier chercher.
Étape 4 — Adapter le composant BoutonReponse
Le bouton reçoit maintenant deux nouvelles props : son etat (pour choisir la couleur) et
desactive (pour ne plus réagir aux appuis après la réponse). Modifiez src/components/BoutonReponse.tsx.
Dans la liste des styles de StyleSheet.create, ajoutez aussi les trois styles bonne, mauvaise et
attenue (voir « Une solution » en fin de TP). Importez le type avec
import type { EtatBouton } from "@/logic/etatBouton";.
BoutonReponse ne sait pas si la réponse est bonne : il reçoit simplement un etat et l'affiche.
C'est l'écran qui décide. Un composant « sans cerveau » se réutilise partout.
Étape 5 — Déclarer l'état avec useState
Dans src/app/index.tsx, l'écran doit se souvenir de la réponse choisie. C'est le rôle de
useState. Importez-le depuis react (import { useState } from "react";), puis déclarez l'état
au début de la fonction Accueil.
Les lignes suivantes calculent trois valeurs à partir de cet état : bonneReponse, aRepondu et
estGagne. Elles ne sont pas stockées avec useState : on les recalcule à chaque affichage.
Ne stockez que ce qui ne peut pas être déduit d'autre chose. Ici, aRepondu se déduit de
reponseChoisieId !== null : le stocker à part obligerait à garder deux variables cohérentes, source
de bugs.
Étape 6 — Réagir au clic
Écrivez la fonction appelée quand le joueur appuie sur un bouton. Elle commence par un garde-fou : si le joueur a déjà répondu, on ne fait rien.
Transmettez ensuite cette fonction et l'état calculé à chaque BoutonReponse dans le map
(etat={calculerEtatBouton(...)}, desactive={aRepondu} et onPress={() => choisirReponse(reponse.id)}).
Le bouton désactivé est la première protection contre le double-clic. Le garde-fou
if (aRepondu) return; est la deuxième : il protège même si un appui parvient à passer avant que
l'écran ait eu le temps d'être mis à jour. En sécurité comme en développement, une seule protection
est rarement suffisante.
Étape 7 — Afficher la correction
Sous la liste des réponses, affichez la correction seulement si le joueur a répondu.
Enregistrez et testez dans le navigateur : choisissez une réponse, observez les couleurs, puis cliquez sur une autre réponse (rien ne doit changer).
{aRepondu && (...)} n'affiche le bloc que si aRepondu vaut true. C'est la même technique que pour
l'image des questions (question.image !== undefined && ...) à la séance sur les images.
Étape 8 — Enregistrer votre travail
Votre fonctionnalité est terminée : enregistrez-la dans l'historique Git, puis publiez la branche
sur GitHub (la première fois, il faut indiquer à Git de créer la branche distante avec -u).
Un commit = une étape de travail terminée, décrite par un message clair. Si vous cassez quelque chose plus tard, vous pourrez revenir à ce point précis de l'historique.
📌 Une solution
Notions théoriques
Pourquoi une variable ordinaire ne suffit pas
Imaginons que l'on stocke le choix du joueur dans une variable ordinaire :
export default function Accueil() {
let reponseChoisieId = null; // variable ordinaire
function choisirReponse(reponseId: number) {
reponseChoisieId = reponseId; // la valeur change... mais l'écran, non !
}
// ...
}
Cela ne fonctionne pas, pour deux raisons :
- React ne sait pas que la variable a changé : il n'a donc aucune raison de réafficher l'écran ;
- même s'il réaffichait l'écran, la fonction
Accueilserait appelée de nouveau depuis le début : la variable serait recréée avec sa valeur initiale (null) et le choix serait perdu.
Il faut un moyen de dire à React : « cette information doit être mémorisée entre deux affichages, et son changement doit déclencher un nouvel affichage ». C'est exactement le rôle de l'état.
useState : mémoriser une valeur
const [reponseChoisieId, setReponseChoisieId] = useState<number | null>(null);
| Élément | Rôle |
|---|---|
useState(null) | demande à React de mémoriser une valeur, initialisée à null au premier affichage |
reponseChoisieId | la valeur actuelle de l'état (à lire, jamais à modifier directement) |
setReponseChoisieId | la fonction de modification : l'appeler change l'état et déclenche un nouvel affichage |
<number | null> | le type de l'état (un nombre, ou null tant que le joueur n'a pas répondu) |
Le déroulement lors d'un clic sur une réponse :
- le joueur appuie sur un bouton :
choisirReponse(13)est appelée ; - elle appelle
setReponseChoisieId(13): React note la nouvelle valeur ; - React rappelle la fonction
Accueil(on dit qu'il fait un nouveau rendu) ; - cette fois,
useStaterenvoie13comme valeur actuelle : l'écran se met à jour avec la correction.
Chaque composant a son propre état. Si deux écrans utilisent useState, ils ne partagent rien.
L'état est aussi perdu quand le composant disparaît de l'écran.
Dans une même exécution de fonction, la valeur lue reste celle du rendu en cours. Après
setReponseChoisieId(13), la variable reponseChoisieId vaut encore null jusqu'à la fin de la
fonction : c'est au prochain rendu qu'elle vaudra 13.
null : « pas encore répondu »
Au départ, le joueur n'a rien choisi. Il faut une valeur qui ne peut pas être confondue avec un
identifiant de réponse. On utilise null (« rien »). Le type number | null (une union, comme au
tutoriel 2) oblige TypeScript à nous rappeler de traiter les deux cas :
const aRepondu = reponseChoisieId !== null;
Les règles des hooks
useState est un hook (un nom qui commence par use). Deux règles s'appliquent à tous les hooks :
- on les appelle uniquement dans un composant (ou dans un autre hook) ;
- on les appelle toujours au début, jamais dans un
if, une boucle ou après unreturn: React identifie chaque hook par son ordre d'appel.
Calculer plutôt que stocker
À partir d'un seul état (reponseChoisieId), l'écran calcule tout le reste :
const bonneReponse = trouverBonneReponse(question);
const aRepondu = reponseChoisieId !== null;
const estGagne = aRepondu && reponseChoisieId === bonneReponse?.id;
C'est une règle essentielle de React : un état minimal, tout le reste est calculé. Ainsi, il est
impossible d'avoir un écran incohérent (par exemple aRepondu à false alors qu'une réponse est
choisie). Cette idée sera au cœur de la séance sur le score.
Une fonction pure pour l'apparence des boutons
calculerEtatBouton renvoie l'un des quatre états du type EtatBouton :
| État | Situation | Apparence |
|---|---|---|
"normal" | le joueur n'a pas encore répondu | bouton blanc |
"bonne" | c'est la bonne réponse (toujours montrée après la réponse) | vert |
"mauvaise" | c'est la réponse choisie, et elle est fausse | rouge |
"attenue" | une autre réponse, sans importance | grisée |
Se protéger du double-clic
Sur un téléphone, un joueur impatient peut appuyer deux fois très vite. Sans protection, la deuxième pression pourrait modifier la réponse déjà donnée. Deux protections sont mises en place :
- le bouton est désactivé (
disabled) dès que le joueur a répondu ; - le gestionnaire
choisirReponseignore tout appui siaReponduvauttrue.
Les séances suivantes renforceront cette protection : le double-clic est un problème classique, qui demande plusieurs « verrous » superposés.
Test de mémorisation/compréhension
Exercices complémentaires
Exercice A : afficher le choix du joueur
Sous la liste des réponses, ajoutez temporairement un <Text> qui affiche
Identifiant choisi : suivi de la valeur de reponseChoisieId (utilisez {reponseChoisieId}).
Cliquez sur une réponse et observez : c'est le moyen le plus simple de « voir » l'état changer. Retirez ensuite ce texte.
Exercice B : un test supplémentaire
Dans tests/etatBouton.test.ts, ajoutez un test qui vérifie que, si le joueur a choisi la bonne
réponse (par exemple calculerEtatBouton(13, 13, 13)), la fonction renvoie bien "bonne" pour ce
bouton, puis qu'une autre réponse (calculerEtatBouton(12, 13, 13)) est "attenue". Lancez npm test.
Ce qu'il faut retenir
| Notion | Résumé |
|---|---|
| État | Information mémorisée par un composant entre deux affichages ; son changement déclenche un nouvel affichage. |
useState | Hook qui renvoie la valeur actuelle et la fonction pour la modifier : const [x, setX] = useState(valeurInitiale). |
null | Valeur qui représente « pas encore de valeur », ici « pas encore répondu ». |
| Calculer plutôt que stocker | Un état minimal ; les autres valeurs (aRepondu, estGagne) se déduisent à chaque affichage. |
| Règles des hooks | Appel au début d'un composant, jamais dans un if ou une boucle. |
| Double-clic | Bouton désactivé (disabled) et garde-fou dans le gestionnaire : deux protections superposées. |
Aperçu de la prochaine séance
Votre écran sait maintenant réagir à un clic, mais il ne propose qu'une seule question. Dans la prochaine séance, vous allez enchaîner cinq questions tirées au hasard : un index de question courante, un écran de fin, et un piège classique à éviter (le hasard recalculé à chaque affichage).