Aller au contenu principal

Annuler une requête avec AbortController

Éviter les réponses qui arrivent dans le désordre en annulant les requêtes devenues inutiles, et compter les questions disponibles dans Supabase.

Objectifs de la séance​

  • comprendre le bug des réponses dans le désordre (et du setState après démontage) ;
  • annuler une requête avec un AbortController et .abortSignal(signal) ;
  • vérifier signal.aborted avant chaque mise à jour d'état et nettoyer dans le useEffect ;
  • compter des lignes sans les télécharger (count: "exact", head: true) ;
  • mémoriser un résultat avec sa clé pour ignorer un résultat périmé ;
  • ranger la logique dans deux hooks personnalisés : useThemes et useNombreQuestionsDisponibles.

Petite vérification​

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

  1. dans le navigateur, couper le réseau (outils de développement F12, onglet Réseau, Hors connexion), recharger le Générateur de quiz et lire le message affiché au-dessus du bouton
  2. citer les trois valeurs possibles de la propriété statut du type EtatChargement
  3. citer le nom de la variable d'état de generateur.tsx dont la modification relance le chargement des thèmes
📌 Réponse attendue

Notions théoriques​

Le bug : des réponses qui arrivent dans le désordre​

Dans le générateur, le joueur choisit un thème et un niveau. Pour chaque réglage, l'application doit afficher le nombre de questions disponibles, qu'elle demande à Supabase. Imaginez cette version « naïve » (elle est donnée ici pour comprendre le problème, ne l'écrivez pas) :

// Version NAÏVE : aucune protection contre les réponses tardives
useEffect(() => {
async function compter() {
const nombre = await compterQuestions(theme, niveau);
setNombreDisponible(nombre);
}
compter();
}, [theme, niveau]);

Le joueur est pressé : il clique sur « Réseaux et Wi-Fi » + « Expert », puis, une demi-seconde plus tard, sur « Tous » + « Tous ». Deux requêtes partent. Le réseau n'est pas régulier :

MomentÉvénementAffichage
0,0 sclic sur Réseaux + Expert : la requête A part (lente)« Comptage... »
0,5 sclic sur Tous + Tous : la requête B part (rapide)« Comptage... »
0,8 sla réponse B arrive : 2727 question(s) (correct)
2,0 sla réponse A arrive : 33 question(s) (faux : les réglages affichés sont Tous + Tous)

L'écran montre un nombre qui ne correspond plus aux réglages choisis. Le joueur peut alors lancer une partie avec des réglages impossibles. C'est un bug intermittent : il dépend de la vitesse du réseau, donc il est difficile à reproduire et à corriger après coup.

Un second bug du même genre : quand l'écran disparaît pendant qu'une requête est en cours, la réponse arrive quand même et tente de mettre à jour l'état d'un composant qui n'existe plus.

Pour observer ces délais sur votre machine, ouvrez les outils du navigateur (F12), onglet Réseau, et choisissez un débit lent (par exemple Slow 3G) : les requêtes durent alors plusieurs secondes.

L'AbortController : annuler une requête​

Un AbortController est un objet JavaScript qui permet d'annuler une opération asynchrone. Il fournit :

  • controleur.signal : un « signal » que l'on transmet à la requête ;
  • controleur.abort() : la méthode qui déclenche l'annulation ;
  • controleur.signal.aborted : un booléen qui vaut true après l'annulation.

Avec Supabase, on transmet le signal à la requête grâce à .abortSignal(signal) :

const { data, error } = await supabase
.from("theme")
.select("code, libelle")
.abortSignal(signal);

Une requête annulée se termine en erreur (une erreur d'annulation). Il faut donc, dans le try comme dans le catch, ne rien faire si le signal a été annulé : c'est le rôle du test if (!controleur.signal.aborted) placé avant chaque setState.

Nettoyer dans le useEffect​

Une fonction renvoyée par un useEffect est sa fonction de nettoyage : React l'appelle avant de relancer l'effet (quand une dépendance change) et quand le composant disparaît.

useEffect(() => {
const controleur = new AbortController();
// ... lancer la requête avec controleur.signal ...
return () => controleur.abort(); // nettoyage : annule la requête en cours
}, [theme, niveau]);

Résultat : quand le joueur change de réglage, la requête précédente est annulée avant que la nouvelle ne parte, et sa réponse tardive est ignorée.

Compter sans télécharger​

Pour afficher « 27 question(s) disponible(s) », inutile de télécharger les 27 questions. Supabase sait renvoyer seulement le nombre de lignes :

supabase.from("question").select("id", { count: "exact", head: true });
  • count: "exact" demande le nombre exact de lignes qui correspondent ;
  • head: true demande de ne renvoyer aucune ligne (seulement l'en-tête de la réponse) ;
  • le nombre se lit dans la propriété count du résultat (qui peut être null, d'où count ?? 0).

Les filtres .eq("theme_code", theme) et .eq("niveau", niveau) ne sont ajoutés que si le joueur a choisi un thème ou un niveau précis (« tous » = pas de filtre).

Le résultat et sa clé​

L'annulation ne suffit pas toujours : entre le moment où le joueur change de réglage et le moment où l'effet est relancé, React affiche une fois l'ancien résultat avec les nouveaux réglages. Pour éviter cet affichage périmé, le hook mémorise le résultat avec la clé des réglages qui l'ont produit (${theme}/${niveau}, par exemple reseaux/3). À l'affichage :

  • si la clé du résultat mémorisé correspond aux réglages actuels, on l'utilise ;
  • sinon, on considère qu'on est encore en chargement.

Cette comparaison ne demande aucun setState supplémentaire : l'état affiché est déduit des réglages actuels et du dernier résultat.

Des hooks personnalisés​

Un hook personnalisé est une fonction dont le nom commence par use et qui regroupe de la logique avec état (useState, useEffect...). Il garde les écrans lisibles :

HookRôle
useThemes()charge les thèmes, expose etatThemes et reessayer
useNombreQuestionsDisponibles(theme, niveau)compte les questions pour des réglages donnés

Les écrans de la suite (par exemple le classement) les réutiliseront tels quels.

Pas de setState sans vérifier le signal

Règle à retenir pour toute la série : aucun setState après un await sans avoir vérifié signal.aborted. Sans cette vérification, une réponse tardive écrase un état plus récent.


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

Votre mission : rendre les chargements annulables, créer les deux hooks, et brancher le générateur sur Supabase pour compter les questions. Vous continuez sur la branche feature/donnees-async.

Étape 1 — Rendre chargerThemes annulable​

Dans src/services/themesService.ts, la fonction reçoit un paramètre signal qu'elle transmet à la requête Supabase :


Bonne pratique - Le signal vient de l'appelant

Le service ne crée pas lui-même le AbortController : c'est le hook (qui connaît le cycle de vie de l'écran) qui le crée et qui décide d'annuler. Le service se contente de transmettre le signal.

Étape 2 — Créer le hook useThemes​

Créez le dossier src/hooks et le fichier src/hooks/useThemes.ts. Le hook déplace dans un fichier à part le code de chargement que vous aviez écrit dans generateur.tsx, en remplaçant le drapeau estAnnule par un AbortController.


Bonne pratique - Toujours nettoyer ce qu'un effet démarre

Une requête, une minuterie, un abonnement : tout ce qu'un useEffect démarre doit être arrêté dans sa fonction de nettoyage. Sinon, les opérations s'accumulent et agissent sur un écran qui n'existe plus.

Étape 3 — Créer le service de comptage​

Créez src/services/questionsService.ts. La fonction compterQuestions demande à Supabase le nombre de questions pour un thème et un niveau, sans télécharger les questions.


Bonne pratique - Ne télécharger que ce dont on a besoin

Compter 27 lignes en les téléchargeant ne coûte presque rien ; compter 100 000 lignes de la même façon saturerait le réseau. Prenez l'habitude de demander à la base de faire le calcul.

Étape 4 — Créer le hook useNombreQuestionsDisponibles​

Créez src/hooks/useNombreQuestionsDisponibles.ts. Il annule l'ancienne requête (comme useThemes) et mémorise chaque résultat avec la clé des réglages (${theme}/${niveau}). À l'affichage, un résultat dont la clé ne correspond plus aux réglages actuels est ignoré.


Bonne pratique - Déduire plutôt que stocker

Le hook ne stocke pas « l'état courant » : il stocke le dernier résultat et déduit l'état à afficher en le comparant aux réglages actuels. Moins d'états à synchroniser, moins de bugs possibles.

Étape 5 — Utiliser les deux hooks dans le générateur​

Dans src/app/generateur.tsx, supprimez l'état etatThemes, l'état tentative, le useEffect et la fonction reessayer, ainsi que les imports devenus inutiles (useEffect, QUESTIONS_LOCALES, filtrerQuestions, chargerThemes, EtatChargement, Theme). Remplacez-les par l'appel aux hooks :

Remplacez aussi l'affichage du nombre disponible par un affichage selon l'état du comptage (voir la solution) : « Comptage des questions... », un message d'erreur, ou « N question(s) disponible(s) ». Le bouton Générer le quiz reste désactivé tant que le comptage n'a pas réussi.

Testez dans le navigateur :

  1. le générateur affiche 27 question(s) disponible(s) pour Tous + Tous ;
  2. choisissez « Réseaux et Wi-Fi » + « Expert » : 3 question(s) ; le bouton « 5 » est grisé ;
  3. dans F12 > Réseau, choisissez Slow 3G puis changez très vite de réglages : le nombre affiché correspond toujours aux derniers réglages choisis ;
  4. passez Hors connexion et changez de réglage : un message d'erreur remplace le nombre.

Bonne pratique - Désactiver tant que les données ne sont pas fiables

Un bouton qui lance une action doit rester désactivé tant que les informations dont il dépend ne sont pas connues. Ici, impossible de générer un quiz avant d'avoir le nombre de questions disponibles.

Étape 6 — Enregistrer votre travail​


Bonne pratique - Un commit par étape logique

Cette séance modifie un service, crée deux hooks et un autre service : un seul commit, car ces changements forment un tout (le générateur ne fonctionne qu'avec les trois).

📌 Une solution

Test de mémorisation/compréhension​


Quel est le problème d'un comptage lancé à chaque changement de réglage, sans protection ?


Que fait controleur.abort() ?


Que renvoie select("id", { count: "exact", head: true }) ?


Pourquoi vérifier controleur.signal.aborted avant chaque setState ?


À quoi sert la clé `${theme}/${niveau}` dans useNombreQuestionsDisponibles ?


Quand React appelle-t-il la fonction de nettoyage renvoyée par un useEffect ?


Comment transmet-on le signal d'annulation à une requête Supabase ?



Exercices complémentaires​

Exercice A : observer l'annulation​

Dans F12 > Réseau, choisissez Slow 3G et changez rapidement de réglages dans le générateur. Repérez les requêtes question?select=id... : certaines apparaissent comme annulées (canceled). Ce sont celles que votre hook a interrompues.

Exercice B : provoquer le bug​

Recopiez temporairement dans un fichier de test la version « naïve » vue dans les Notions théoriques (sans AbortController ni clé), branchez-la sur le générateur, et essayez de reproduire l'affichage d'un nombre qui ne correspond pas aux réglages. Supprimez ensuite cet essai et revenez à votre code.


Ce qu'il faut retenir​

NotionRésumé
Réponses dans le désordrePlusieurs requêtes en parallèle ne se terminent pas forcément dans l'ordre où elles sont parties.
AbortControllerObjet qui fournit un signal et une méthode abort() pour annuler une opération asynchrone.
.abortSignal(signal)Transmet le signal à une requête Supabase.
signal.abortedÀ tester avant chaque setState suivant un await.
Nettoyage du useEffectreturn () => controleur.abort(); annule la requête quand l'effet est relancé ou que l'écran disparaît.
count: "exact", head: trueDemande à Supabase uniquement le nombre de lignes.
Résultat avec sa cléMémoriser le résultat avec les réglages qui l'ont produit permet d'ignorer un résultat périmé.
Hooks personnalisésuseThemes et useNombreQuestionsDisponibles regroupent la logique de chargement et allègent les écrans.

Aperçu de la prochaine séance​

Le générateur sait désormais compter les questions disponibles. Mais lorsque le joueur clique sur Générer le quiz, la partie utilise encore les questions écrites dans le code ! Dans la prochaine séance, vous allez charger les vraies questions (avec leurs quatre réponses) depuis Supabase, en une seule requête, et vérifier que chaque question reçue est correctement formée.