Cours JavaScript et React · Module 10: Écosystème et avenir de React

React Query / TanStack Query - gestion moderne de l'état serveur

9 min de lecture
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 optionnels

Les 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 :

  1. Montage - il n'y a pas de données, donc status vaut pending et fetchStatus vaut fetching. C'est seulement dans ce cas que isLoading, c'est-à-dire isPending && isFetching, vaut true.
  2. Succès - les données sont arrivées : status passe à success et fetchStatus à idle. Pendant la durée staleTime, les données sont fraîches et la bibliothèque n'envoie pas de nouvelles requêtes.
  3. Données périmées - une fois staleTime écoulé, les données deviennent périmées (isStale vaut true), mais cela seul ne déclenche encore aucune récupération. Par défaut, staleTime vaut 0, donc les données sont périmées dès leur récupération.
  4. 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) ou invalidateQueries, lance une récupération. status reste success et les anciennes données restent visibles, tandis que fetchStatus passe à fetching, donc isFetching vaut true et isLoading false.
  5. De nouveau inactive - les données fraîches remplacent les anciennes, fetchStatus revient à idle, et le décompte de staleTime recommence.

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. 1. Qu'est-ce qui est obligatoire dans la configuration de useQuery ?

  2. 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 :

Articles utiles