Aller au contenu principal

Charger les questions avec fetch

Aller chercher des données, afficher l'attente, gérer l'échec, et ne jamais faire confiance à ce qui arrive.

Objectifs de la séance​

  • servir un fichier de données depuis le dossier public/ ;
  • appeler fetch dans un effet avec async/await ;
  • représenter le chargement avec un état unique (chargement, succes, erreur) ;
  • annuler la requête quand le composant disparaît (AbortController) ;
  • valider les données reçues (unknown et fonction de contrôle) ;
  • proposer un bouton Réessayer.

Petite vérification​

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

  1. lancer une partie et lire le temps affiché par le chronomètre
  2. regarder le titre de l'onglet du navigateur pendant la partie, puis à la fin de la partie
  3. dire pourquoi le chronomètre avance deux fois trop vite quand on retire clearInterval en développement
📌 Réponse attendue

Notions théoriques​

Un fichier de données servi par Vite​

Dans une vraie application, les questions viendraient d'un serveur (une API). Pour apprendre sans serveur à installer, on utilise un fichier JSON placé dans le dossier public/ : Vite le sert tel quel, comme le ferait une API.

quiz-cyber-web/
└── public/
└── questions.json ← accessible à l'adresse /questions.json

Pour que l'application fonctionne même hébergée dans un sous-dossier (https://exemple.fr/quiz/), on préfixe l'adresse par import.meta.env.BASE_URL, une valeur fournie par Vite.

const adresse = `${import.meta.env.BASE_URL}questions.json`;

Les trois états d'un chargement​

Un chargement est toujours dans un seul de ces états : en cours, réussi, échoué. Une union d'objets (comme dans la partie 6 du cours JavaScript) l'exprime sans contradiction possible.

src/types/quiz.ts
export type EtatChargement =
| { statut: "chargement" }
| { statut: "succes"; questions: Question[] }
| { statut: "erreur"; message: string };

Dans App, l'état initial est { statut: "chargement" } : au premier affichage, les données n'existent pas encore.

fetch : le schéma à connaître par cœur​

const reponse = await fetch(adresse, { signal }); // 1) la requête (annulable)

if (!reponse.ok) { // 2) vérifier le code HTTP
throw new Error(`Le serveur a répondu ${reponse.status}`);
}

const donnees: unknown = await reponse.json(); // 3) lire le JSON, comme « inconnu »
  • fetch ne rompt sa promesse que si le réseau est coupé : une réponse 404 ou 500 est considérée comme réussie, d'où le test de reponse.ok ;
  • reponse.json() renvoie le type any : on l'affecte à une variable unknown pour obliger TypeScript à nous faire vérifier avant d'utiliser.

Valider les données reçues​

Les types TypeScript disparaissent à l'exécution : le fichier (ou le serveur) peut contenir n'importe quoi. Une fonction de contrôle vérifie la forme réelle des données.

export function estQuestion(valeur: unknown): valeur is Question {
if (typeof valeur !== "object" || valeur === null) {
return false;
}
const candidat = valeur as Record<string, unknown>;
return (
typeof candidat.id === "number" &&
typeof candidat.enonce === "string" &&
(candidat.niveau === 1 || candidat.niveau === 2 || candidat.niveau === 3) &&
(candidat.indice === undefined || typeof candidat.indice === "string") &&
Array.isArray(candidat.reponses) &&
candidat.reponses.length > 0 &&
candidat.reponses.every(estReponse)
);
}

Puis on garde uniquement les éléments valides, en prévenant le développeur de ce qui a été écarté :

const questions = donnees.filter(estQuestion);
if (questions.length < donnees.length) {
console.warn(`${donnees.length - questions.length} question(s) invalide(s) ignorée(s)`);
}
if (questions.length === 0) {
throw new Error("Aucune question valide n'a été trouvée");
}

Une donnée invalide écartée avec un avertissement vaut mieux qu'un écran qui plante sur un champ manquant.

Charger dans un effet, et annuler​

On lance le chargement au premier affichage de App. L'effet crée un AbortController et l'annule dans son nettoyage.

useEffect(() => {
const controleur = new AbortController();

async function charger() {
try {
const questions = await chargerQuestions(controleur.signal);
setChargement({ statut: "succes", questions });
} catch (erreur) {
if (controleur.signal.aborted) {
return; // annulation volontaire : on n'affiche rien
}
setChargement({ statut: "erreur", message: messageErreur(erreur) });
}
}

charger();

return () => {
controleur.abort();
};
}, [tentative]);

Points importants :

  1. une fonction async est définie dans l'effet puis appelée : useEffect(async () => ...) est interdit, car une fonction async renvoie une promesse alors que React attend un nettoyage ;
  2. l'annulation évite de mettre à jour l'état d'un composant disparu, et d'afficher une réponse périmée (le piège des « réponses dans le désordre » de la partie 5) ;
  3. controleur.signal.aborted distingue une annulation volontaire d'une vraie erreur.
Dans StrictMode, la requête part deux fois

En développement, l'effet s'exécute deux fois : la première requête est annulée par le nettoyage. Dans l'onglet Réseau des outils de développement (touche F12), vous voyez deux requêtes questions.json, la première étant annulée. En production, une seule requête part.

Le bouton « Réessayer »​

Pour relancer le chargement, on ajoute un état tentative aux dépendances de l'effet : l'incrémenter relance l'effet. L'état repasse à « chargement » dans la fonction d'événement qui répond au clic.

const [tentative, setTentative] = useState(0);

function reessayer() {
setChargement({ statut: "chargement" });
setTentative((ancien) => ancien + 1);
}

Test de mémorisation/compréhension​


Dans quel dossier du projet place-t-on un fichier servi tel quel par Vite ?


Que se passe-t-il quand le serveur répond 404 à un fetch ?


Pourquoi affecter reponse.json() à une variable de type unknown ?


Pourquoi ne peut-on pas écrire useEffect(async () => { ... }) ?


Que fait controleur.abort() appelé dans le nettoyage de l'effet ?


Comment relance-t-on le chargement avec le bouton Réessayer ?



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

Votre mission : charger les questions depuis un fichier JSON, avec chargement, erreur, annulation, validation et bouton « Réessayer ».

Étape 1 — Le fichier de données​

Créez le fichier public/questions.json avec les sept questions suivantes, puis supprimez src/data/questions.ts (devenu inutile).


Bonne pratique - Du JSON valide

Le JSON est plus strict que JavaScript : guillemets doubles obligatoires, pas de virgule finale, pas de commentaire. En cas d'erreur, reponse.json() lève une exception : notre try/catch l'attrapera.

Étape 2 — L'état du chargement​

Ajoutez le type EtatChargement à la fin de src/types/quiz.ts.


Bonne pratique - Impossible d'être en chargement et en erreur

Une seule variable, trois formes possibles : les états contradictoires (enChargement: true et erreur: "..." en même temps) ne peuvent pas s'écrire.

Étape 3 — Les fonctions de contrôle​

Créez src/api/questions.ts. Commencez par messageErreur et la vérification d'une réponse.


Bonne pratique - as seulement dans une fonction de contrôle

L'assertion valeur as Record<string, unknown> est tolérée ici car chaque propriété est ensuite vérifiée avec typeof. Ailleurs, as sert à faire taire TypeScript : on l'évite.

Étape 4 — La fonction de chargement​

Ajoutez chargerQuestions au même fichier.


Bonne pratique - Le signal est un paramètre

chargerQuestions reçoit le signal en paramètre : c'est l'appelant (l'effet) qui décide d'annuler. La fonction reste simple et réutilisable.

Étape 5 — L'effet dans App​

Dans App.tsx, déclarez les états et écrivez l'effet de chargement.


Bonne pratique - Annuler = ne jamais mettre à jour un état périmé

Si la requête est annulée, on sort du catch sans appeler setChargement : un composant démonté ou une requête obsolète ne touche plus à l'état.

Étape 6 — Les écrans de chargement et d'erreur​

Terminez App : bouton « Réessayer », démarrage protégé, affichage selon le statut.


Bonne pratique - Tester les chemins d'échec

Un chemin d'erreur jamais exécuté est un chemin qui ne fonctionne probablement pas. Testez : (1) renommez questions.json en questions2.json puis cliquez sur « Réessayer » après l'avoir remis ; (2) supprimez le champ enonce d'une question : elle est ignorée et la console affiche un avertissement ; (3) dans les outils de développement (F12, onglet Réseau), choisissez « Slow 3G » pour voir le message de chargement.

📌 Une solution

Ce qu'il faut retenir​

NotionRésumé
public/Fichiers servis tels quels ; adresse préfixée par import.meta.env.BASE_URL.
EtatChargementUne union chargement / succes / erreur : jamais d'état contradictoire.
fetchToujours tester reponse.ok, lire le JSON comme unknown, puis valider.
ValidationUne fonction de contrôle valeur is Type filtre les données reçues.
Effet asynchroneFonction async définie dans l'effet ; AbortController annulé dans le nettoyage.
Annulationcontroleur.signal.aborted distingue annulation et vraie erreur.
RéessayerUn état tentative dans les dépendances relance l'effet.

Aperçu de la prochaine séance​

App mélange maintenant l'affichage et toute la logique de chargement. Dans la prochaine séance, vous allez ranger cette logique dans un hook personnalisé useQuestions, que n'importe quel composant pourra réutiliser en une ligne.