Aller au contenu principal

Un chronomètre avec useEffect

Mesurer la durée de la partie avec un hook personnalisé, et ne jamais laisser une minuterie tourner dans le vide.

Objectifs de la séance​

  • comprendre ce qu'est un effet et quand useEffect s'exécute ;
  • utiliser le tableau de dépendances ;
  • écrire la fonction de nettoyage (clearInterval) pour ne jamais laisser une minuterie active ;
  • éviter la valeur périmée grâce à la mise à jour fonctionnelle ;
  • créer un hook personnalisé useChronometre et une fonction pure formaterDuree.

Petite vérification​

Avant de commencer, vérifiez si vous savez...

  1. exécuter npm test et lire le nombre de tests réussis
  2. citer les trois statuts possibles d'une partie dans partieReducer.ts
  3. citer la fonction de l'écran src/app/index.tsx dans laquelle le hasard est utilisé pour recommencer une partie
📌 Réponse attendue

TP pour réfléchir et résoudre des problèmes​

Votre mission : afficher, dans le badge de la partie, le temps écoulé (« 00:42 »), l'arrêter à la fin du quiz et l'afficher sur l'écran de résultat.

Étape 1 — Mettre le temps en forme​

Créez src/logic/duree.ts. La fonction formaterDuree transforme un nombre de secondes en texte « mm:ss » (par exemple 75 secondes donne 01:15).

Remarquez la première ligne de la fonction : une valeur invalide (négative, NaN...) est ramenée à 0 pour que l'écran n'affiche jamais « NaN:NaN ».


Bonne pratique - Protéger les fonctions d'affichage

Une fonction qui met en forme une valeur doit accepter n'importe quelle entrée sans planter. Un chronomètre qui afficherait « NaN:NaN » ferait mauvaise impression, et le bug est évitable en une ligne.

Étape 2 — Tester la mise en forme​

Créez tests/duree.test.ts.

Lancez npm test : tests 29 et pass 29.


Bonne pratique - Tester les valeurs limites

Le fichier de test contient 0, 5, 75 et 600 secondes, mais aussi une valeur négative et NaN. Les cas limites sont ceux où les bugs se cachent.

Étape 3 — Créer le hook : l'état du chronomètre​

Créez le dossier src/hooks/ et le fichier useChronometre.ts. Un hook personnalisé est une fonction dont le nom commence par use et qui appelle d'autres hooks. Le chronomètre mémorise d'abord un nombre de secondes.

Le paramètre enCours indique si le chronomètre doit tourner (true) ou être arrêté (false).


Bonne pratique - Extraire la logique dans un hook

Le chronomètre n'a rien à voir avec les questions : le mettre dans un hook dédié allège l'écran et permet de le réutiliser. L'écran n'a plus qu'à écrire useChronometre(...).

Étape 4 — Lancer une minuterie dans un effet​

Une minuterie est un effet de bord : une action qui se produit « à côté » de l'affichage. On la lance avec useEffect. Si enCours vaut false, on ne lance rien.


Bonne pratique - Mémoriser l'identifiant de la minuterie

setInterval renvoie un identifiant. Sans le conserver dans une variable, il serait impossible d'arrêter la minuterie plus tard.

Étape 5 — Augmenter les secondes sans valeur périmée​

À l'intérieur de la minuterie, on augmente les secondes d'une unité. Attention : la fonction passée à setInterval est créée une seule fois ; elle garderait pour toujours la valeur de secondes du premier affichage (zéro). On utilise donc la mise à jour fonctionnelle.


Bonne pratique - La mise à jour fonctionnelle évite les valeurs périmées

Avec setSecondes(secondes + 1), le chronomètre s'arrêterait à 1 seconde : chaque tic relirait la même valeur secondes figée. La forme fonctionnelle repart toujours de la dernière valeur connue.

Étape 6 — Nettoyer la minuterie​

Voici la ligne la plus importante du hook : la fonction de nettoyage, renvoyée par l'effet. React l'appelle avant de relancer l'effet et quand l'écran disparaît.

Terminez le hook en écrivant remettreAZero et son return { secondes, remettreAZero }; (voir « Une solution »).


Bonne pratique - Tout effet qui démarre quelque chose doit l'arrêter

Minuterie, écoute d'un événement, requête réseau : si l'effet démarre quelque chose, le nettoyage doit l'arrêter. Sans cela, la minuterie continuerait à tourner (et à modifier l'état) même après le départ du joueur de l'écran, et les minuteries s'accumuleraient à chaque changement de enCours.

Étape 7 — Utiliser le hook dans l'écran​

Dans src/app/index.tsx, importez useChronometre (depuis @/hooks/useChronometre) et formaterDuree (depuis @/logic/duree). Le chronomètre tourne tant que la partie n'est pas terminée.


Bonne pratique - Une condition plutôt qu'un démarrage manuel

On ne « démarre » ni n'« arrête » le chronomètre par des appels : on lui indique s'il doit tourner. Quand le statut passe à "terminee", la valeur change, l'effet est nettoyé (minuterie arrêtée) et relancé sans minuterie.

Étape 8 — Afficher le temps et remettre à zéro​

Affichez formaterDuree(secondes) dans le badge de l'en-tête et sur l'écran de fin (Temps : ...). Dans rejouer, remettez le chronomètre à zéro.

Jouez une partie : le temps s'écoule, s'arrête sur l'écran de fin, puis repart de zéro avec « Rejouer ».


Bonne pratique - Observer pour vérifier

Pour vérifier qu'une minuterie est bien nettoyée, jouez, terminez la partie, et observez : le temps affiché sur l'écran de fin ne doit plus bouger.

Étape 9 — Enregistrer votre travail​


Bonne pratique - Publier régulièrement

git push envoie vos commits sur GitHub : votre travail est sauvegardé en dehors de votre ordinateur.

Étape 10 — Fusionner la branche du tutoriel​

Le tutoriel est terminé : il faut maintenant fusionner la branche feature/etat dans main avec une Pull Request (comme vous l'avez fait pour la palette de couleurs).

  1. sur GitHub, ouvrez la page de votre dépôt quiz-cyber-expo et cliquez sur Compare & pull request ;
  2. donnez un titre à la Pull Request (par exemple « Tutoriel 4 : état et interactions »), relisez la liste des fichiers modifiés (Files changed), puis cliquez sur Create pull request ;
  3. cliquez sur Merge pull request, puis Confirm merge ;
  4. de retour dans le terminal, retournez sur main et récupérez la version fusionnée.

Bonne pratique - Relire avant de fusionner

L'onglet Files changed d'une Pull Request est un moment de relecture : on y repère les fichiers modifiés par erreur, les console.log oubliés ou un secret ajouté par accident. Vous pouvez ensuite supprimer la branche fusionnée avec git branch -d feature/etat.

📌 Une solution

Notions théoriques​

Qu'est-ce qu'un effet ?​

Un composant React a pour rôle principal de calculer ce qu'il faut afficher à partir de son état. Mais certaines actions ne sont pas de l'affichage : lancer une minuterie, interroger un serveur, écouter un événement. On les appelle des effets de bord (side effects). Elles doivent se faire à côté du calcul de l'affichage, au bon moment : c'est le rôle de useEffect.

useEffect(() => {
// l'effet : exécuté APRÈS l'affichage
return () => {
// le nettoyage : exécuté AVANT la prochaine exécution de l'effet,
// et quand le composant disparaît de l'écran
};
}, [dependances]);

Le tableau de dépendances​

Le second paramètre contrôle quand l'effet est (ré)exécuté :

TableauComportement
[]l'effet s'exécute une seule fois, après le premier affichage
[enCours]l'effet est exécuté après le premier affichage, puis à chaque fois que enCours change
(absent)l'effet est exécuté après chaque affichage (presque toujours une erreur)

Règle d'or : le tableau doit contenir toutes les valeurs de l'état ou des props que l'effet utilise. Ici, l'effet utilise enCours, donc [enCours]. (setSecondes est garanti stable par React : il n'a pas besoin d'y figurer.)

La fonction de nettoyage​

Quand enCours passe de true à false, que se passe-t-il ?

  1. React appelle d'abord le nettoyage de l'exécution précédente : clearInterval(minuterie) arrête la minuterie ;
  2. puis il rappelle l'effet : cette fois enCours vaut false, l'effet fait return; sans rien lancer.

Sans nettoyage, la première minuterie continuerait à tourner. Pire : à chaque changement de enCours à true, une nouvelle minuterie s'ajouterait à l'ancienne, et le chronomètre avancerait deux fois plus vite. C'est le bug classique de la « minuterie fantôme ».

En développement, l'effet s'exécute parfois deux fois

En mode développement, React peut exécuter l'effet, le nettoyer, puis le ré-exécuter, uniquement pour vérifier que votre nettoyage est correct. Si le nettoyage est bien écrit, vous ne voyez aucune différence. C'est un test gratuit : un nettoyage oublié se verrait immédiatement (le chronomètre irait deux fois trop vite).

setInterval et clearInterval​

  • setInterval(fonction, 1000) exécute fonction toutes les 1000 millisecondes et renvoie un identifiant ;
  • clearInterval(identifiant) arrête cette répétition.

Le piège de la valeur périmée​

// MAUVAIS : "secondes" est figée à la valeur du rendu où la minuterie a été créée
setInterval(() => setSecondes(secondes + 1), 1000); // 0, puis 1, puis 1, puis 1...

// BON : on part de la dernière valeur connue
setInterval(() => setSecondes((precedentes) => precedentes + 1), 1000);

La fonction passée à setInterval a été créée lors d'un affichage précis : elle « se souvient » de la valeur qu'avait secondes à ce moment-là (on parle de fermeture, closure). La forme fonctionnelle contourne le problème.

Un hook personnalisé​

Un hook personnalisé est une simple fonction dont le nom commence par use et qui appelle d'autres hooks (useState, useEffect...). Il permet de réutiliser une logique avec état, et d'alléger les écrans :

const { secondes, remettreAZero } = useChronometre(etat.statut !== "terminee");

Les mêmes règles que pour les hooks de React s'appliquent : appel au début du composant, jamais dans une condition.

formaterDuree et padStart​

String(5).padStart(2, "0") complète le texte à gauche avec des zéros jusqu'à 2 caractères : "5" devient "05". Associé à Math.floor(total / 60) pour les minutes et total % 60 (le reste de la division) pour les secondes, on obtient mm:ss.


Test de mémorisation/compréhension​


Quand la fonction passée à useEffect est-elle exécutée ?


Que fait la fonction renvoyée par un effet (le nettoyage) ?


Que signifie le tableau de dépendances [enCours] ?


Pourquoi écrit-on setSecondes((precedentes) => precedentes + 1) dans la minuterie ?


Que se passe-t-il si l'on oublie le clearInterval dans le nettoyage ?


Que renvoie formaterDuree(75) ?



Exercices complémentaires​

Exercice A : le test de la limite​

Dans tests/duree.test.ts, ajoutez un test qui vérifie que formaterDuree(3599) renvoie "59:59". Lancez npm test.

Exercice B : observer le nettoyage​

Dans useChronometre.ts, ajoutez temporairement console.log("Minuterie démarrée") juste après la création de la minuterie, et console.log("Minuterie arrêtée") dans la fonction de nettoyage. Jouez une partie en regardant la console du navigateur : quand chaque message apparaît-il ? Retirez-les ensuite.


Ce qu'il faut retenir​

NotionRésumé
EffetAction « à côté » de l'affichage (minuterie, requête, écoute), lancée par useEffect après l'affichage.
Dépendances[enCours] : l'effet est relancé quand enCours change ; [] : une seule fois.
NettoyageLa fonction renvoyée par l'effet arrête ce qu'il a démarré (clearInterval).
Valeur périméeDans une minuterie, utiliser la mise à jour fonctionnelle setX((ancien) => ...).
Hook personnaliséFonction dont le nom commence par use ; réutilise useState, useEffect...
formaterDureeFonction pure testée qui transforme des secondes en mm:ss (Math.floor, %, padStart).

Aperçu de la prochaine séance​

Votre partie est complète, mais elle vit dans un seul écran. Dans le tutoriel suivant, vous allez découvrir Expo Router : la navigation entre écrans par fichiers. Vous déplacerez le jeu dans son propre écran partie, créerez un véritable écran d'accueil, puis un générateur de quiz où le joueur choisit thème, niveau et nombre de questions.