Cours JavaScript et React · Module 10: Écosystème et avenir de React
React Query / TanStack Query - gestion moderne de l'état serveur
Dans cette leçon6
Le panneau de mission affiche le profil d'un astronaute dans trois composants à la fois. Avec useEffect et fetch, chacun envoie une requête séparée, aucun ne mémorise le résultat, et passer rapidement d'un profil à l'autre peut afficher les données de la mauvaise personne. React Query, aujourd'hui appelé TanStack Query, change la façon dont tu récupères, mets en cache et synchronises l'état serveur avec l'interface. Comme un système de navigation qui suit tout seul la position du vaisseau, la bibliothèque gère elle-même l'état de tes données.
Pourquoi React Query plutôt que useEffect + fetch ?
Regarde l'approche traditionnelle et la liste de ses problèmes dans les commentaires :
1// Approche traditionnelle - BEAUCOUP DE PROBLÈMES
2function UserProfile({ userId }) {
3 const [user, setUser] = useState(null);
4 const [loading, setLoading] = useState(true);
5 const [error, setError] = useState(null);
6
7 useEffect(() => {
8 setLoading(true);
9 fetch(`/api/users/${userId}`)
10 .then(res => res.json())
11 .then(data => {
12 setUser(data);
13 setLoading(false);
14 })
15 .catch(err => {
16 setError(err);
17 setLoading(false);
18 });
19 }, [userId]);
20
21 // Problèmes :
22 // 1. Pas de cache - les mêmes données sont récupérées plusieurs fois
23 // 2. Pas de refetching automatique
24 // 3. Pas de mises à jour optimistes
25 // 4. Pas de déduplication des requêtes
26 // 5. Pas de gestion des données périmées
27 // 6. Il faut gérer à la main les états loading/error
28 // 7. Problèmes de race conditions
29
30 if (loading) return <div>Chargement...</div>;
31 if (error) return <div>Erreur : {error.message}</div>;
32 return <div>{user.name}</div>;
33}Tu gères les états loading et error à la main, et quand userId change rapidement, une réponse plus ancienne peut écraser une plus récente : c'est une situation de concurrence (race condition). Rien ici n'est mis en cache ni dédupliqué.
Le même composant avec TanStack Query ressemble à ceci :
1// React Query - ÉLÉGANT ET PUISSANT
2import { useQuery } from '@tanstack/react-query';
3
4function UserProfile({ userId }) {
5 const { data: user, isPending, error } = useQuery({
6 queryKey: ['user', userId],
7 queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json())
8 });
9
10 if (isPending) return <div>Chargement...</div>;
11 if (error) return <div>Erreur : {error.message}</div>;
12 return <div>{user.name}</div>;
13}Chaque requête a besoin de deux choses : queryKey, une clé unique dans le cache, et queryFn, une fonction qui renvoie une promesse avec les données. La clé ['user', userId] donne à chaque utilisateur une entrée séparée, et trois composants avec la même clé enverront une seule requête. isPending signifie qu'il n'y a pas encore de données, donc après les deux conditions user est bien défini. N'oublie pas que fetch ne lève pas d'erreur sur un statut 404 ou 500, donc vérifie res.ok, comme le fait createMission plus bas.
Installation et configuration de base
Tu installes le paquet principal et les outils de développement optionnels :
1# Installation de TanStack Query (React Query v5)
2npm install @tanstack/react-query
3npm install @tanstack/react-query-devtools # DevTools optionnelsLes DevTools n'arrivent dans le bundle qu'en mode développement, donc tu n'as pas besoin de les retirer avant le déploiement.
QueryClient stocke le cache et les options par défaut des requêtes, et QueryClientProvider le met à disposition de l'application :
1// App.jsx - Setup QueryClient
2import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
4
5// Créer le Query Client avec sa configuration
6const queryClient = new QueryClient({
7 defaultOptions: {
8 queries: {
9 staleTime: 1000 * 60 * 5, // 5 minutes
10 gcTime: 1000 * 60 * 30, // 30 minutes
11 refetchOnWindowFocus: true,
12 refetchOnReconnect: true,
13 retry: 3,
14 retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
15 },
16 },
17});
18
19function App() {
20 return (
21 <QueryClientProvider client={queryClient}>
22 <YourApp />
23 {/* DevTools - visibles uniquement en développement */}
24 <ReactQueryDevtools initialIsOpen={false} />
25 </QueryClientProvider>
26 );
27}staleTime indique combien de temps les données restent fraîches, et gcTime (cacheTime dans la version 4) après combien de temps une entrée inutilisée disparaît de la mémoire. Par défaut, staleTime vaut 0 et gcTime 5 minutes.
Le cycle de vie d'une requête : status et fetchStatus
La stratégie stale-while-revalidate consiste à afficher tout de suite les données du cache, même périmées, pendant qu'en arrière-plan la bibliothèque récupère des données fraîches et les remplace dans l'interface. Pour savoir ce qui se passe à un instant donné, TanStack Query v5 décrit une requête avec deux champs indépendants. status concerne les données : pending (il n'y en a pas encore, loading dans la version 4), error ou success. fetchStatus concerne la fonction queryFn : fetching (elle s'exécute), paused (elle veut récupérer, mais il n'y a pas de réseau) ou idle (elle ne fait rien). Les valeurs idle et loading du champ status n'existent plus :
1function MissionCounter() {
2 const { data, status, fetchStatus, isStale } = useQuery({
3 queryKey: ['missions'],
4 queryFn: fetchMissions,
5 staleTime: 30 * 1000, // Les données restent fraîches pendant 30 secondes
6 });
7
8 if (status === 'pending') return <p>Premier chargement (fetchStatus : {fetchStatus})</p>;
9 if (status === 'error') return <p>Échec de la récupération des missions</p>;
10
11 return (
12 <p>
13 Missions : {data.length}, données {isStale ? 'périmées' : 'fraîches'}
14 {fetchStatus === 'fetching' && ' - actualisation en arrière-plan'}
15 </p>
16 );
17}Voici le parcours typique d'une requête depuis le montage du composant :
- Montage - il n'y a pas de données, donc
statusvautpendingetfetchStatusvautfetching. C'est seulement dans ce cas queisLoading, c'est-à-direisPending && isFetching, vauttrue. - Succès - les données sont arrivées :
statuspasse àsuccessetfetchStatusàidle. Pendant la duréestaleTime, les données sont fraîches et la bibliothèque n'envoie pas de nouvelles requêtes. - Données périmées - une fois
staleTimeécoulé, les données deviennent périmées (isStalevauttrue), mais cela seul ne déclenche encore aucune récupération. Par défaut,staleTimevaut 0, donc les données sont périmées dès leur récupération. - Actualisation en arrière-plan - un déclencheur, comme un nouveau composant avec la même clé, le retour sur l'onglet du navigateur (
refetchOnWindowFocus), le retour du réseau (refetchOnReconnect) ouinvalidateQueries, lance une récupération.statusrestesuccesset les anciennes données restent visibles, tandis quefetchStatuspasse àfetching, doncisFetchingvauttrueetisLoadingfalse. - De nouveau inactive - les données fraîches remplacent les anciennes,
fetchStatusrevient àidle, et le décompte destaleTimerecommence.
Sans réseau, la requête n'échoue pas : elle passe en fetchStatus: 'paused' et reprend toute seule au retour de la connexion. refetchInterval actualise les données périodiquement, indépendamment de staleTime.
useMutation - requêtes POST, PUT et DELETE
Les requêtes lisent les données, et les mutations les modifient. useMutation reçoit une mutationFn et n'envoie rien de lui-même, il renvoie seulement un objet avec une méthode mutate :
1import { useMutation, useQueryClient } from '@tanstack/react-query';
2
3async function createMission(newMission) {
4 const response = await fetch('/api/missions', {
5 method: 'POST',
6 headers: { 'Content-Type': 'application/json' },
7 body: JSON.stringify(newMission),
8 });
9
10 if (!response.ok) {
11 throw new Error('Failed to create mission');
12 }
13
14 return response.json();
15}
16
17function CreateMissionForm() {
18 const queryClient = useQueryClient();
19
20 const mutation = useMutation({
21 mutationFn: createMission,
22
23 // Optimistic update
24 onMutate: async (newMission) => {
25 await queryClient.cancelQueries({ queryKey: ['missions'] });
26 const previousMissions = queryClient.getQueryData(['missions']);
27 queryClient.setQueryData(['missions'], (old) => [...old, newMission]);
28 return { previousMissions };
29 },
30
31 // Rollback on error
32 onError: (err, newMission, context) => {
33 queryClient.setQueryData(['missions'], context.previousMissions);
34 },
35
36 // Invalidate and refetch
37 onSettled: () => {
38 queryClient.invalidateQueries({ queryKey: ['missions'] });
39 },
40 });
41
42 const handleSubmit = (e) => {
43 e.preventDefault();
44 const formData = new FormData(e.target);
45
46 mutation.mutate({
47 name: formData.get('name'),
48 destination: formData.get('destination'),
49 date: formData.get('date'),
50 });
51 };
52
53 return (
54 <form onSubmit={handleSubmit}>
55 <input name="name" placeholder="Nom de la mission" required />
56 <input name="destination" placeholder="Destination de la mission" required />
57 <input name="date" type="date" required />
58
59 <button type="submit" disabled={mutation.isPending}>
60 {mutation.isPending ? 'Création...' : 'Créer la mission'}
61 </button>
62 </form>
63 );
64}C'est une mise à jour optimiste : onMutate ajoute tout de suite la mission au cache, avant la réponse du serveur, et mémorise la liste précédente. En cas d'erreur, onError la restaure depuis context, et onSettled invalide la clé ['missions'], donc la liste se synchronise de toute façon avec le serveur. cancelQueries arrête une récupération en cours pour qu'elle n'écrase pas la modification, et isPending désactive le bouton.
Pagination avec React Query
Avec la pagination, le numéro de page entre dans la clé, donc chaque page a une entrée séparée dans le cache :
1import { useQuery, keepPreviousData } from '@tanstack/react-query';
2
3function PaginatedMissions() {
4 const [page, setPage] = useState(1);
5
6 const { data, isLoading, isPlaceholderData } = useQuery({
7 queryKey: ['missions', page],
8 queryFn: () => fetchMissions(page),
9 placeholderData: keepPreviousData,
10 });
11
12 return (
13 <div>
14 {isLoading ? (
15 <div>Chargement...</div>
16 ) : (
17 <>
18 <div className="missions-list">
19 {data.missions.map(mission => (
20 <div key={mission.id} className="mission-item">
21 <h3>{mission.name}</h3>
22 </div>
23 ))}
24 </div>
25
26 <div className="pagination">
27 <button
28 onClick={() => setPage(old => Math.max(old - 1, 1))}
29 disabled={page === 1}
30 >
31 Précédente
32 </button>
33
34 <span>Page {page} sur {data.totalPages}</span>
35
36 <button
37 onClick={() => setPage(old => old + 1)}
38 disabled={isPlaceholderData || page >= data.totalPages}
39 >
40 Suivante
41 </button>
42 </div>
43 </>
44 )}
45 </div>
46 );
47}placeholderData: keepPreviousData garde la page précédente à l'écran jusqu'à l'arrivée de la suivante, donc la liste ne clignote pas. isPlaceholderData t'indique que tu vois encore les anciennes données, c'est pourquoi le bouton Suivante attend.
Infinite Queries - défilement infini
useInfiniteQuery stocke toutes les pages récupérées d'une liste infinie dans data.pages :
1import { useInfiniteQuery } from '@tanstack/react-query';
2
3function InfiniteMissions() {
4 const {
5 data,
6 fetchNextPage,
7 hasNextPage,
8 isFetchingNextPage,
9 status,
10 } = useInfiniteQuery({
11 queryKey: ['infiniteMissions'],
12 queryFn: ({ pageParam }) => fetch(`/api/missions?page=${pageParam}&limit=20`).then(r => r.json()),
13 initialPageParam: 1,
14 getNextPageParam: (lastPage, allPages) => {
15 return lastPage.hasMore ? allPages.length + 1 : undefined;
16 },
17 });
18
19 if (status === 'pending') return <div>Chargement...</div>;
20 if (status === 'error') return <div>Erreur de chargement</div>;
21
22 return (
23 <div>
24 <div className="infinite-list">
25 {data.pages.map((page, i) => (
26 <React.Fragment key={i}>
27 {page.missions.map(mission => (
28 <div key={mission.id} className="mission-card">
29 <h3>{mission.name}</h3>
30 </div>
31 ))}
32 </React.Fragment>
33 ))}
34 </div>
35
36 <button
37 onClick={() => fetchNextPage()}
38 disabled={!hasNextPage || isFetchingNextPage}
39 >
40 {isFetchingNextPage
41 ? 'Chargement...'
42 : hasNextPage
43 ? 'Charger plus'
44 : 'Plus de données'}
45 </button>
46 </div>
47 );
48}initialPageParam (obligatoire dans la version 5) fixe la première page, et getNextPageParam renvoie le numéro de la page suivante ou undefined quand il n'y a plus de données, ce qui met hasNextPage à false.
Je te recommande de garder les données du serveur dans TanStack Query, et seulement l'état de l'interface, comme les panneaux ouverts, dans useState ou Redux, car ce sont deux types d'état différents. La bibliothèque automatise la récupération, la mise en cache, la synchronisation et la mise à jour des données comme un pilote automatique qui corrige lui-même sa trajectoire, pendant que tu te concentres sur l'expérience utilisateur. Elle te servira dans chaque projet avec une API, car queryFn peut être n'importe quelle fonction qui renvoie une promesse, y compris un client GraphQL.
Retiens : une clé de requête, ce sont des coordonnées dans le cache - les mêmes coordonnées désignent les mêmes données, et les données périmées se rafraîchissent en arrière-plan. Dans l'exemple ci-dessous, observe les champs status et fetchStatus : attends 5 secondes que les données soient périmées, reviens sur l'onglet, puis ajoute une mission, y compris avec une erreur serveur simulée.
Code de cette leçon : App.jsx
1import { useEffect, useState, useSyncExternalStore } from 'react';
2
3// === TanStack Query v5 - une simulation simplifiée du cache ===
4// Dans un projet : npm install @tanstack/react-query et useQuery({ queryKey, queryFn, staleTime }).
5// Ce mini-client montre les mêmes champs que la bibliothèque : status (pending | error | success),
6// fetchStatus (fetching | paused | idle) et des données fraîches ou périmées une fois staleTime écoulé.
7
8const STALE_TIME = 5000; // Les données restent fraîches pendant 5 secondes
9const startedAt = Date.now();
10
11// Un faux serveur avec une liste de missions
12let serverMissions = [
13 { id: 1, name: 'Apollo-RQ1', status: 'active', crew: 4 },
14 { id: 2, name: 'Nebula-X7', status: 'terminée', crew: 6 },
15 { id: 3, name: 'Orion-3', status: 'planifiée', crew: 3 },
16];
17
18const api = {
19 getMissions: () => new Promise(resolve => setTimeout(() => resolve([...serverMissions]), 800)),
20 addMission: (mission, fail) => new Promise((resolve, reject) => setTimeout(() => {
21 if (fail) {
22 reject(new Error('Le serveur a refusé la mission'));
23 return;
24 }
25 const saved = { ...mission, id: Date.now() };
26 serverMissions = [...serverMissions, saved];
27 resolve(saved);
28 }, 700)),
29};
30
31// L’entrée du cache pour la clé ['missions'] - QueryClient le fait pour toi
32function createQuery(queryFn) {
33 let state = { status: 'pending', fetchStatus: 'idle', data: undefined, error: null, dataUpdatedAt: 0, isStale: true, log: [] };
34 let inFlight = null;
35 let fetchId = 0;
36 let staleTimer = null;
37 const listeners = new Set();
38
39 const setState = (patch, message) => {
40 const time = ((Date.now() - startedAt) / 1000).toFixed(1) + ' s';
41 const log = message ? [...state.log, { time, message }].slice(-10) : state.log;
42 state = { ...state, ...patch, log };
43 listeners.forEach(listener => listener());
44 };
45
46 const markFresh = (data, message) => {
47 clearTimeout(staleTimer);
48 setState({ status: 'success', data, error: null, dataUpdatedAt: Date.now(), isStale: false, fetchStatus: 'idle' }, message);
49 staleTimer = setTimeout(() => setState({ isStale: true }, 'staleTime écoulé : données périmées, mais rien n’est encore récupéré'), STALE_TIME);
50 };
51
52 const query = {
53 subscribe(listener) {
54 listeners.add(listener);
55 return () => listeners.delete(listener);
56 },
57 getState() {
58 return state;
59 },
60 fetch(reason) {
61 if (inFlight) return inFlight; // Déduplication : une seule requête à la fois
62 if (state.status === 'success') {
63 setState({ fetchStatus: 'fetching' }, 'fetchStatus: fetching, status toujours success - actualisation en arrière-plan (' + reason + ')');
64 } else {
65 setState({ fetchStatus: 'fetching' }, 'status: pending, fetchStatus: fetching - premier chargement (isLoading: true)');
66 }
67 const id = ++fetchId;
68 inFlight = queryFn()
69 .then(data => {
70 if (id === fetchId) markFresh(data, 'status: success, fetchStatus: idle - données fraîches pendant ' + STALE_TIME / 1000 + ' s');
71 })
72 .catch(error => {
73 if (id === fetchId) setState({ status: 'error', error, fetchStatus: 'idle' }, 'status: error - ' + error.message);
74 })
75 .finally(() => {
76 if (id === fetchId) inFlight = null;
77 });
78 return inFlight;
79 },
80 // cancelQueries : le résultat de la récupération en cours sera ignoré
81 cancel() {
82 if (!inFlight) return;
83 fetchId++;
84 inFlight = null;
85 setState({ fetchStatus: 'idle' }, 'cancelQueries : récupération en cours annulée');
86 },
87 refetchIfStale(reason) {
88 if (state.isStale) query.fetch(reason);
89 else if (state.status === 'success') setState({}, reason + ' : données fraîches, donc pas de nouvelle requête');
90 },
91 invalidate(reason) {
92 clearTimeout(staleTimer);
93 setState({ isStale: true });
94 return query.fetch(reason);
95 },
96 setData(updater, message) {
97 markFresh(updater(state.data), message);
98 },
99 };
100 return query;
101}
102
103const missionsQuery = createQuery(api.getMissions);
104
105// L’équivalent de useQuery({ queryKey: ['missions'], queryFn, staleTime: 5000 })
106function useMissionsQuery() {
107 const state = useSyncExternalStore(missionsQuery.subscribe, missionsQuery.getState);
108
109 // Montage du composant : récupère si les données manquent ou sont périmées
110 useEffect(() => {
111 missionsQuery.refetchIfStale('nouveau composant');
112 }, []);
113
114 // refetchOnWindowFocus : le retour sur l’onglet n’actualise que les données périmées
115 useEffect(() => {
116 const onFocus = () => missionsQuery.refetchIfStale('retour sur la fenêtre');
117 window.addEventListener('focus', onFocus);
118 return () => window.removeEventListener('focus', onFocus);
119 }, []);
120
121 return {
122 ...state,
123 isPending: state.status === 'pending',
124 isFetching: state.fetchStatus === 'fetching',
125 isLoading: state.status === 'pending' && state.fetchStatus === 'fetching',
126 };
127}
128
129function Badge({ label, value, color }) {
130 return (
131 <span style={{ ...styles.badge, borderColor: color, color }}>
132 {label}: <strong>{String(value)}</strong>
133 </span>
134 );
135}
136
137function QueryStatus({ query }) {
138 return (
139 <div style={styles.card}>
140 <div style={styles.badges}>
141 <Badge label="status" value={query.status} color="#00d4ff" />
142 <Badge label="fetchStatus" value={query.fetchStatus} color={query.isFetching ? '#ffa500' : '#8892b0'} />
143 <Badge label="isLoading" value={query.isLoading} color="#8892b0" />
144 <Badge label="isFetching" value={query.isFetching} color="#8892b0" />
145 <Badge label="données" value={query.isStale ? 'périmées' : 'fraîches'} color={query.isStale ? '#ff6b6b' : '#00ff88'} />
146 </div>
147 <div style={styles.staleTrack}>
148 {!query.isStale && <div key={query.dataUpdatedAt} style={styles.staleFill} />}
149 </div>
150 <div style={styles.buttons}>
151 <button style={styles.button} onClick={() => missionsQuery.refetchIfStale('retour sur la fenêtre')}>
152 Simuler le retour sur l’onglet
153 </button>
154 <button style={styles.button} onClick={() => missionsQuery.invalidate('invalidateQueries')}>
155 invalidateQueries(['missions'])
156 </button>
157 </div>
158 </div>
159 );
160}
161
162function AddMission({ ready }) {
163 const [name, setName] = useState('');
164 const [failServer, setFailServer] = useState(false);
165 const [isPending, setIsPending] = useState(false);
166
167 // L’équivalent de useMutation avec une mise à jour optimiste
168 const addMission = async () => {
169 const mission = { name: name.trim(), status: 'planifiée', crew: 2 };
170 if (!mission.name || isPending || !ready) return;
171 setName('');
172 setIsPending(true);
173
174 // onMutate : annule la récupération en cours, mémorise la liste et ajoute tout de suite la mission
175 missionsQuery.cancel();
176 const previous = missionsQuery.getState().data;
177 missionsQuery.setData(old => [...old, { ...mission, id: 'temp-' + Date.now(), optimistic: true }], 'onMutate : mission ajoutée de façon optimiste');
178 try {
179 await api.addMission(mission, failServer);
180 } catch (error) {
181 // onError : restaure la liste d’avant la modification
182 missionsQuery.setData(() => previous, 'onError: ' + error.message + ' - la liste précédente a été restaurée');
183 } finally {
184 // onSettled : invalide la clé pour se synchroniser avec le serveur
185 setIsPending(false);
186 missionsQuery.invalidate('onSettled');
187 }
188 };
189
190 return (
191 <div style={styles.form}>
192 <input
193 value={name}
194 onChange={e => setName(e.target.value)}
195 onKeyDown={e => e.key === 'Enter' && addMission()}
196 placeholder="Nom de la nouvelle mission..."
197 style={styles.input}
198 />
199 <button onClick={addMission} disabled={isPending || !ready} style={styles.primary}>
200 {isPending ? 'Enregistrement...' : 'Ajouter la mission'}
201 </button>
202 <label style={styles.check}>
203 <input type="checkbox" checked={failServer} onChange={e => setFailServer(e.target.checked)} />
204 Simuler une erreur serveur
205 </label>
206 </div>
207 );
208}
209
210export default function App() {
211 const query = useMissionsQuery();
212
213 return (
214 <div style={styles.page}>
215 <div style={{ maxWidth: '640px', margin: '0 auto' }}>
216 <h1 style={styles.title}>TanStack Query v5 - le cycle de vie d’une requête</h1>
217 <p style={styles.subtitle}>staleTime : {STALE_TIME / 1000} s. Attends que les données soient périmées, puis reviens sur l’onglet.</p>
218
219 <QueryStatus query={query} />
220
221 <div style={styles.card}>
222 {query.isPending ? (
223 <p style={styles.muted}>Chargement des missions...</p>
224 ) : query.status === 'error' ? (
225 <p style={{ color: '#ff6b6b' }}>Erreur : {query.error.message}</p>
226 ) : (
227 query.data.map(m => (
228 <div key={m.id} style={{ ...styles.mission, opacity: m.optimistic ? 0.6 : 1 }}>
229 <strong>{m.name}</strong>
230 <span style={styles.muted}>
231 {m.optimistic ? 'enregistrement en cours (optimiste)' : m.status + ', équipage : ' + m.crew}
232 </span>
233 </div>
234 ))
235 )}
236 <AddMission ready={query.status === 'success'} />
237 </div>
238
239 <div style={styles.card}>
240 <h3 style={styles.logTitle}>Journal de la requête</h3>
241 {query.log.map((entry, i) => (
242 <div key={i} style={styles.logRow}>
243 <span style={styles.logTime}>{entry.time}</span>
244 <span>{entry.message}</span>
245 </div>
246 ))}
247 </div>
248 </div>
249 <style>{'@keyframes staleCountdown { from { width: 100%; } to { width: 0%; } }'}</style>
250 </div>
251 );
252}
253
254const styles = {
255 page: { minHeight: '100vh', background: 'linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%)', padding: '20px', color: '#e0e1dd', fontFamily: 'system-ui, sans-serif', fontSize: '13px' },
256 title: { textAlign: 'center', color: '#00d4ff', margin: '0 0 6px', fontSize: '20px' },
257 subtitle: { textAlign: 'center', color: '#8892b0', margin: '0 0 14px' },
258 card: { background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(0,212,255,0.15)', borderRadius: '10px', padding: '12px', marginBottom: '12px' },
259 badges: { display: 'flex', flexWrap: 'wrap', gap: '6px' },
260 badge: { border: '1px solid', borderRadius: '6px', padding: '3px 8px', fontSize: '12px' },
261 staleTrack: { height: '4px', background: 'rgba(255,255,255,0.08)', borderRadius: '2px', margin: '10px 0', overflow: 'hidden' },
262 staleFill: { height: '100%', background: '#00ff88', animation: 'staleCountdown 5s linear forwards' },
263 buttons: { display: 'flex', gap: '8px', flexWrap: 'wrap' },
264 button: { padding: '6px 12px', background: 'rgba(0,212,255,0.15)', color: '#00d4ff', border: '1px solid rgba(0,212,255,0.4)', borderRadius: '6px', cursor: 'pointer', fontSize: '12px' },
265 mission: { display: 'flex', justifyContent: 'space-between', gap: '8px', padding: '8px 10px', margin: '6px 0', background: 'rgba(0,212,255,0.06)', borderRadius: '6px' },
266 muted: { color: '#8892b0' },
267 form: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: '10px' },
268 input: { flex: '1 1 180px', padding: '7px 10px', background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(0,212,255,0.3)', borderRadius: '6px', color: '#e0e1dd' },
269 primary: { padding: '7px 14px', background: '#00d4ff', color: '#0a0e27', border: 'none', borderRadius: '6px', fontWeight: 'bold', cursor: 'pointer' },
270 check: { display: 'flex', alignItems: 'center', gap: '6px', color: '#8892b0' },
271 logTitle: { margin: '0 0 8px', color: '#64ffda', fontSize: '14px' },
272 logRow: { display: 'flex', gap: '8px', padding: '2px 0', fontSize: '12px' },
273 logTime: { color: '#8892b0', width: '48px', flexShrink: 0, textAlign: 'right' },
274};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. Qu'est-ce qui est obligatoire dans la configuration de useQuery ?
2. Qu'est-ce qu'une mise à jour optimiste dans le contexte de useMutation ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri horizontal
Compose les éléments d'un appel à useMutation dans React Query de gauche à droite :
- Tri vertical
Ordonne le cycle de vie d'une requête dans TanStack Query v5, du montage du composant à l'actualisation en arrière-plan :