Cours JavaScript et React · Module 11: Récupération de données et API
Mise en cache des données et Stale-While-Revalidate
Dans cette leçon8
Imagine que ton vaisseau spatial demande à plusieurs reprises au Centre de contrôle les mêmes données planétaires. À chaque fois, tu attends une réponse, même si les données n'ont pas changé. Quel gâchis ! La solution est la mise en cache : stocker les données localement dans un cache et les rafraîchir en arrière-plan.
Pourquoi la mise en cache est-elle importante ?
Comparons deux ponts du même vaisseau.
Sans cache :
- Chaque navigation = nouvelle requête = spinner
- L'utilisateur voit « Chargement... » même pour des données déjà vues
- Charge inutile sur le serveur et le réseau
Avec cache :
- Données affichées instantanément depuis la mémoire
- Rafraîchissement en arrière-plan (sans spinner)
- Meilleure expérience utilisateur
Un cache a pourtant un prix : les données en mémoire peuvent être obsolètes. Tout l'art consiste à savoir quand leur faire confiance et quand redemander.
Le pattern Stale-While-Revalidate (SWR)
SWR est une stratégie de mise en cache connue du protocole HTTP (la directive stale-while-revalidate de l'en-tête Cache-Control) et utilisée par des bibliothèques comme SWR ou TanStack Query. Le nom dit tout :
- Stale : affiche immédiatement les données du cache (même si elles peuvent être obsolètes)
- While-Revalidate : en même temps, récupère des données fraîches du serveur en arrière-plan
- À l'arrivée des données fraîches, mets à jour la vue
Le même flux décomposé en étapes ressemble à ceci :
1// Visualisation de SWR :
2// Étape 1 : L'utilisateur arrive sur la page
3// -> Le cache existe ? OUI -> Afficher les données du cache (instantanément !)
4// -> En même temps : envoyer une requête au serveur
5
6// Étape 2 : Le serveur répond
7// -> Les données ont changé ? OUI -> Mettre à jour la vue et le cache
8// -> Données identiques ? -> Ne rien faire
9
10// Étape 3 : L'utilisateur revient sur la page
11// -> Recommencer à l'Étape 1 (affichage instantané !)L'utilisateur n'attend ni au premier retour ni aux suivants, et le serveur est quand même interrogé. SWR n'économise donc pas de requêtes, il économise du temps d'attente.
Cache simple avec Map
Le cache le plus simple peut être construit avec un objet Map, un dictionnaire clé-valeur. La clé sera l'URL, et la valeur les données accompagnées d'un horodatage. La map vit en dehors du composant, donc elle survit à son démontage. Voici un custom hook qui l'utilise :
1// Cache global - vit en dehors des composants
2const cache = new Map();
3
4function useFetchWithCache(url, ttl = 60000) {
5 const [data, setData] = useState(() => {
6 const cached = cache.get(url);
7 if (cached && Date.now() - cached.timestamp < ttl) {
8 return cached.data; // Données instantanées depuis le cache !
9 }
10 return null;
11 });
12 const [loading, setLoading] = useState(!cache.has(url));
13 const [error, setError] = useState(null);
14
15 useEffect(() => {
16 const controller = new AbortController();
17
18 async function fetchData() {
19 try {
20 // Si on a du cache - ne pas afficher de spinner
21 if (!cache.has(url)) setLoading(true);
22 setError(null);
23
24 const response = await fetch(url, {
25 signal: controller.signal,
26 });
27 if (!response.ok) {
28 throw new Error(`HTTP ${response.status}`);
29 }
30 const result = await response.json();
31
32 // Enregistrer dans le cache avec un timestamp
33 cache.set(url, {
34 data: result,
35 timestamp: Date.now(),
36 });
37 setData(result);
38 } catch (err) {
39 if (err.name !== 'AbortError') {
40 setError(err.message);
41 }
42 } finally {
43 setLoading(false);
44 }
45 }
46
47 fetchData();
48 return () => controller.abort();
49 }, [url, ttl]);
50
51 return { data, loading, error };
52}La fonction passée à useState (initialisation paresseuse) ne s'exécute qu'au premier rendu et sort immédiatement les données du cache. L'effet interroge malgré tout toujours le serveur, et c'est précisément la partie « revalidate ». Tout le reste, AbortController, response.ok et les états, vient directement des leçons précédentes.
TTL (Time To Live) - durée de vie du cache
Le TTL définit combien de temps les données du cache sont considérées comme à jour. Dans notre hook, il décide s'il faut afficher tout de suite les données en mémoire au montage. La requête fraîche en arrière-plan part de toute façon :
1// TTL = 60000ms (1 minute)
2// Les données de plus d'1 minute seront récupérées à nouveau
3
4// TTL court (5-30s) - données qui changent souvent
5// ex. prix des cryptomonnaies, statut des missions
6const liveData = useFetchWithCache('/api/status', 5000);
7
8// TTL moyen (1-5 min) - données modérément variables
9// ex. liste des planètes, catalogue des vaisseaux
10const catalog = useFetchWithCache('/api/planets', 60000);
11
12// TTL long (10-60 min) - données qui changent rarement
13// ex. configuration, listes statiques
14const config = useFetchWithCache('/api/config', 600000);Choisir un TTL est une décision métier, pas technique. Demande-toi à quel point voir des données vieilles d'une minute nuirait à l'utilisateur.
Invalidation du cache - forcer un rafraîchissement
Parfois, on sait avec certitude que les données du cache sont obsolètes, par exemple juste après l'ajout d'une nouvelle mission. On supprime alors l'entrée à la main, c'est-à-dire qu'on invalide le cache :
1// Invalidation simple - supprimer du cache
2function invalidateCache(url) {
3 cache.delete(url);
4}
5
6// Après l'ajout d'une nouvelle mission - invalider la liste des missions
7async function createMission(data) {
8 await fetch('/api/missions', {
9 method: 'POST',
10 headers: { 'Content-Type': 'application/json' },
11 body: JSON.stringify(data),
12 });
13
14 // Forcer le rafraîchissement de la liste des missions
15 invalidateCache('/api/missions');
16}La règle est simple : chaque mutation (POST, PUT, DELETE) qui modifie des données sur le serveur doit invalider le cache de ces données.
Optimistic Updates - mettre à jour avant la réponse
Au lieu d'attendre le serveur, on peut mettre à jour l'UI immédiatement et n'annuler le changement que si le serveur refuse. L'essentiel est de mémoriser l'état précédent pour avoir quelque chose à restaurer :
1function MissionList() {
2 const [missions, setMissions] = useState([]);
3
4 const deleteMission = async (id) => {
5 // 1. Supprimer de l'UI de façon OPTIMISTE (instantanément !)
6 const previousMissions = [...missions];
7 setMissions(prev => prev.filter(m => m.id !== id));
8
9 try {
10 // 2. Envoyer la requête DELETE au serveur
11 const response = await fetch(`/api/missions/${id}`, {
12 method: 'DELETE',
13 });
14 // fetch ne lève pas d'erreur pour 4xx/5xx - on vérifie nous-mêmes
15 if (!response.ok) throw new Error(`HTTP ${response.status}`);
16 // Succès - l'UI est déjà à jour !
17 } catch (error) {
18 // 3. ERREUR - restaurer l'état précédent (rollback)
19 setMissions(previousMissions);
20 alert('Impossible de supprimer la mission');
21 }
22 };
23
24 return (
25 <ul>
26 {missions.map(m => (
27 <li key={m.id}>
28 {m.name}
29 <button onClick={() => deleteMission(m.id)}>
30 Supprimer
31 </button>
32 </li>
33 ))}
34 </ul>
35 );
36}La vérification de response.ok est indispensable ici : sans elle, une réponse 500 n'atteindrait jamais le catch et le rollback ne s'exécuterait jamais. Je te recommande les optimistic updates pour les opérations qui réussissent presque toujours, comme un « j'aime » ou le retrait d'un élément d'une liste.
TanStack Query -- mise en cache professionnelle
Dans les grands projets, au lieu d'écrire notre propre cache, on utilise la bibliothèque TanStack Query (anciennement React Query, actuellement en version 5). C'est comme un ordinateur de bord avancé qui gère automatiquement toute la communication. On enveloppe l'application dans QueryClientProvider et on récupère les données avec le hook useQuery :
1import {
2 useQuery,
3 useMutation,
4 useQueryClient,
5 QueryClient,
6 QueryClientProvider,
7} from '@tanstack/react-query';
8
9const queryClient = new QueryClient();
10
11function App() {
12 return (
13 <QueryClientProvider client={queryClient}>
14 <PlanetList />
15 </QueryClientProvider>
16 );
17}
18
19function PlanetList() {
20 // useQuery - cache automatique, SWR, retry, refetch
21 const { data, isLoading, error } = useQuery({
22 queryKey: ['planets'],
23 queryFn: () =>
24 fetch('https://swapi.dev/api/planets/')
25 .then(res => res.json()),
26 staleTime: 60000, // Données fraîches pendant 1 minute
27 gcTime: 300000, // Supprimer du cache après 5 minutes
28 });
29
30 if (isLoading) return <p>Scan de la galaxie...</p>;
31 if (error) return <p>Erreur : {error.message}</p>;
32
33 return (
34 <ul>
35 {data.results.map(planet => (
36 <li key={planet.name}>{planet.name}</li>
37 ))}
38 </ul>
39 );
40}Il n'y a ici aucun useState ni useEffect : la bibliothèque fournit les états de chargement et d'erreur ainsi que le cache. Une réserve tirée de la documentation : queryFn doit lever une erreur pour que la requête passe à l'état error, et fetch() ne lève pas d'erreur pour les codes 4xx/5xx. En production, ajoute donc une vérification de res.ok.
TanStack Query - concepts clés
Voici les options les plus importantes réunies au même endroit. queryKey identifie les données dans le cache, staleTime indique combien de temps elles restent fraîches, et gcTime (appelé cacheTime en version 4) quand les données inutilisées sont supprimées :
1// 1. queryKey - identifiant unique des données dans le cache
2// Changement de clé = nouvelle requête
3const planets = useQuery({
4 queryKey: ['planets', page],
5 queryFn: () => fetchPlanets(page),
6});
7
8// 2. staleTime - combien de temps les données sont fraîches
9// staleTime: 0 - toujours rafraîchir (par défaut)
10// staleTime: 60000 - fraîches pendant 1 minute
11// staleTime: Infinity - ne jamais rafraîchir
12
13// 3. gcTime (garbage collection) - quand supprimer du cache
14// gcTime: 300000 - supprimer après 5 min d'inactivité
15
16// 4. Refetch automatique :
17// - Quand la fenêtre retrouve le focus
18// - Quand le réseau revient après une déconnexion
19// - À intervalles réguliers (refetchInterval)
20
21// 5. useMutation - opérations d'écriture avec invalidation
22const mutation = useMutation({
23 mutationFn: (newMission) =>
24 fetch('/api/missions', {
25 method: 'POST',
26 body: JSON.stringify(newMission),
27 }),
28 onSuccess: () => {
29 // Après l'ajout d'une mission - invalider le cache de la liste
30 queryClient.invalidateQueries({
31 queryKey: ['missions']
32 });
33 },
34});Le staleTime par défaut est 0 et le gcTime par défaut est de 5 minutes. staleTime: Infinity désactive seulement le rafraîchissement automatique, l'invalidation manuelle fonctionne toujours. Dans un composant, tu obtiens le client avec const queryClient = useQueryClient(), et dans un vrai POST, ajoute l'en-tête Content-Type vu dans la leçon précédente. Remarque que invalidateQueries est exactement notre invalidation manuelle, simplement faite à ta place.
Retiens : le cache, c'est la mémoire de l'ordinateur de bord, il montre ce que tu sais déjà et vérifie en arrière-plan si la galaxie a changé.
Code de cette leçon : App.jsx
1import React, { useState, useEffect } from 'react';
2
3// Cache global avec Map
4const cache = new Map();
5let fetchCount = 0;
6
7function useFetchWithCache(url, ttl = 30000) {
8 const [data, setData] = useState(() => {
9 const cached = cache.get(url);
10 if (cached && Date.now() - cached.timestamp < ttl) {
11 return cached.data;
12 }
13 return null;
14 });
15 const [loading, setLoading] = useState(!cache.has(url));
16 const [error, setError] = useState(null);
17 const [source, setSource] = useState('');
18
19 useEffect(() => {
20 const controller = new AbortController();
21
22 // Vérifie le cache
23 const cached = cache.get(url);
24 if (cached && Date.now() - cached.timestamp < ttl) {
25 setData(cached.data);
26 setSource('cache (TTL : ' + Math.round((ttl - (Date.now() - cached.timestamp)) / 1000) + 's restant)');
27 setLoading(false);
28 // SWR : récupère en arrière-plan malgré le cache
29 }
30
31 async function fetchData() {
32 try {
33 if (!cached) setLoading(true);
34 setError(null);
35 fetchCount++;
36 const currentFetch = fetchCount;
37
38 const response = await fetch(url, { signal: controller.signal });
39 if (!response.ok) throw new Error(`HTTP ${response.status}`);
40 const result = await response.json();
41
42 cache.set(url, { data: result, timestamp: Date.now() });
43 setData(result);
44 setSource('serveur (fetch #' + currentFetch + ')');
45 } catch (err) {
46 if (err.name !== 'AbortError') setError(err.message);
47 } finally {
48 setLoading(false);
49 }
50 }
51
52 fetchData();
53 return () => controller.abort();
54 }, [url, ttl]);
55
56 const invalidate = () => {
57 cache.delete(url);
58 setSource('cache invalidé - nouvelle récupération...');
59 setData(null);
60 setLoading(true);
61 };
62
63 return { data, loading, error, source, invalidate };
64}
65
66function PlanetPanel() {
67 const { data, loading, error, source, invalidate } = useFetchWithCache(
68 'https://swapi.dev/api/planets/', 30000
69 );
70
71 return (
72 <div className="panel">
73 <h3>Planètes</h3>
74 <div className="source-badge">{source || 'loading...'}</div>
75 <button className="btn" onClick={invalidate}>Invalider le cache</button>
76 {loading && <div className="mini-loading"><div className="spinner"></div></div>}
77 {error && <p className="error-text">{error}</p>}
78 {data && data.results.slice(0, 5).map(p => (
79 <div key={p.name} className="item">{p.name} - {p.climate}</div>
80 ))}
81 </div>
82 );
83}
84
85function PeoplePanel() {
86 const { data, loading, error, source, invalidate } = useFetchWithCache(
87 'https://swapi.dev/api/people/', 30000
88 );
89
90 return (
91 <div className="panel">
92 <h3>Personnages</h3>
93 <div className="source-badge">{source || 'loading...'}</div>
94 <button className="btn" onClick={invalidate}>Invalider le cache</button>
95 {loading && <div className="mini-loading"><div className="spinner"></div></div>}
96 {error && <p className="error-text">{error}</p>}
97 {data && data.results.slice(0, 5).map(p => (
98 <div key={p.name} className="item">{p.name} - {p.birth_year}</div>
99 ))}
100 </div>
101 );
102}
103
104function CacheStatus() {
105 const [, forceUpdate] = useState(0);
106 useEffect(() => {
107 const interval = setInterval(() => forceUpdate(n => n + 1), 1000);
108 return () => clearInterval(interval);
109 }, []);
110
111 return (
112 <div className="cache-status">
113 <h3>État du cache (Map)</h3>
114 <p>Entrées : {cache.size}</p>
115 {Array.from(cache.entries()).map(([key, val]) => (
116 <div key={key} className="cache-entry">
117 <span className="cache-key">{key.replace('https://swapi.dev/api/', '/')}</span>
118 <span className="cache-age">il y a {Math.round((Date.now() - val.timestamp) / 1000)}s</span>
119 </div>
120 ))}
121 <button className="btn danger" onClick={() => { cache.clear(); forceUpdate(n => n + 1); }}>Vider tout le cache</button>
122 </div>
123 );
124}
125
126function App() {
127 const [showPanels, setShowPanels] = useState(true);
128
129 return (
130 <div className="dashboard">
131 <h1>Démo Cache & SWR</h1>
132 <p>Démonte les panneaux puis remonte-les - les données apparaissent instantanément depuis le cache !</p>
133 <button className="btn toggle" onClick={() => setShowPanels(!showPanels)}>
134 {showPanels ? 'Démonter les panneaux' : 'Monter les panneaux (données du cache !)'}
135 </button>
136 {showPanels && (
137 <div className="grid">
138 <PlanetPanel />
139 <PeoplePanel />
140 </div>
141 )}
142 <CacheStatus />
143 </div>
144 );
145}
146
147export default App;Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Que signifie le nom du pattern 'Stale-While-Revalidate' (SWR) ?
2. Quand faut-il 'invalider' (supprimer/rafraîchir) le cache dans une application React ?
Tâches pratiques dans le jeu
- Tri vertical
Ordonne les étapes du pattern Stale-While-Revalidate :
- Éditeur de code
Implémentation d'un cache avec TTL
- Éditeur de code
Optimistic updates avec rollback
- Clique dans l'ordre
Clique sur les éléments dans l'ordre pour configurer useQuery avec TanStack Query :