Aller au contenu principal

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

  1. lancer npx expo start, ouvrir l'application dans le navigateur (touche w) et lire l'énoncé de la question affichée à l'écran, ainsi que l'illustration qui l'accompagne
  2. cliquer sur une réponse, ouvrir la console du navigateur (touche F12, onglet Console) et lire le message qui s'y affiche
  3. exécuter npm test dans 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.

Avant de commencer

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 :


Bonne pratique - Une branche par tutoriel

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

Bonne pratique - Mettre la logique dans une fonction pure

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


Bonne pratique - Tester la logique avant l'interface

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


Bonne pratique - Un composant d'affichage ne décide rien

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.


Bonne pratique - Un état minimal

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


Bonne pratique - Ceinture et bretelles

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


Bonne pratique - Le rendu conditionnel avec &&

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


Bonne pratique - Un commit par séance

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 :

  1. React ne sait pas que la variable a changé : il n'a donc aucune raison de réafficher l'écran ;
  2. même s'il réaffichait l'écran, la fonction Accueil serait 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émentRôle
useState(null)demande à React de mémoriser une valeur, initialisée à null au premier affichage
reponseChoisieIdla valeur actuelle de l'état (à lire, jamais à modifier directement)
setReponseChoisieIdla 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 :

  1. le joueur appuie sur un bouton : choisirReponse(13) est appelée ;
  2. elle appelle setReponseChoisieId(13) : React note la nouvelle valeur ;
  3. React rappelle la fonction Accueil (on dit qu'il fait un nouveau rendu) ;
  4. cette fois, useState renvoie 13 comme valeur actuelle : l'écran se met à jour avec la correction.
Un état par composant

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.

La valeur ne change pas « tout de suite »

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 un return : 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 :

ÉtatSituationApparence
"normal"le joueur n'a pas encore répondubouton 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 fausserouge
"attenue"une autre réponse, sans importancegrisé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 :

  1. le bouton est désactivé (disabled) dès que le joueur a répondu ;
  2. le gestionnaire choisirReponse ignore tout appui si aRepondu vaut true.

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​


Pourquoi une variable ordinaire ne permet-elle pas de faire changer l'écran ?


Que renvoie l'appel useState(null) ?


Que se passe-t-il quand on appelle setReponseChoisieId(13) ?


Pourquoi utilise-t-on null comme valeur initiale de reponseChoisieId ?


Pourquoi aRepondu n'est-il pas déclaré avec useState ?


Que garantit la ligne « if (aRepondu) { return; } » dans choisirReponse ?



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​

NotionRésumé
ÉtatInformation mémorisée par un composant entre deux affichages ; son changement déclenche un nouvel affichage.
useStateHook qui renvoie la valeur actuelle et la fonction pour la modifier : const [x, setX] = useState(valeurInitiale).
nullValeur qui représente « pas encore de valeur », ici « pas encore répondu ».
Calculer plutôt que stockerUn état minimal ; les autres valeurs (aRepondu, estGagne) se déduisent à chaque affichage.
Règles des hooksAppel au début d'un composant, jamais dans un if ou une boucle.
Double-clicBouton 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).