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
fetchdans un effet avecasync/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 (
unknownet fonction de contrôle) ; - proposer un bouton Réessayer.
Petite vérification
Avant de commencer, vérifiez si vous savez...
- lancer une partie et lire le temps affiché par le chronomètre
- regarder le titre de l'onglet du navigateur pendant la partie, puis à la fin de la partie
- dire pourquoi le chronomètre avance deux fois trop vite quand on retire
clearIntervalen 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.
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 »
fetchne 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 dereponse.ok;reponse.json()renvoie le typeany: on l'affecte à une variableunknownpour 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 :
- une fonction
asyncest définie dans l'effet puis appelée :useEffect(async () => ...)est interdit, car une fonctionasyncrenvoie une promesse alors que React attend un nettoyage ; - 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) ;
controleur.signal.aborteddistingue une annulation volontaire d'une vraie erreur.
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
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).
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.
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.
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.
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.
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.
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
| Notion | Résumé |
|---|---|
public/ | Fichiers servis tels quels ; adresse préfixée par import.meta.env.BASE_URL. |
EtatChargement | Une union chargement / succes / erreur : jamais d'état contradictoire. |
fetch | Toujours tester reponse.ok, lire le JSON comme unknown, puis valider. |
| Validation | Une fonction de contrôle valeur is Type filtre les données reçues. |
| Effet asynchrone | Fonction async définie dans l'effet ; AbortController annulé dans le nettoyage. |
| Annulation | controleur.signal.aborted distingue annulation et vraie erreur. |
| Réessayer | Un é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.