Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 920 minIntermédiaire

TanStack Query (React Query) : charger et mettre en cache les données

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • distinguer l'état de l'interface de l'état du serveur ;
  • charger et mettre en cache des données avec useQuery ;
  • choisir des clés de requête qui contiennent tout ce dont dépend la requête ;
  • modifier des données avec useMutation et invalider le cache.

4 exercices corrigés1 schéma

TanStack Query, longtemps appelé React Query, est la bibliothèque la plus utilisée pour gérer les données serveur dans une application React : celles qui vivent dans une base, derrière une API, et dont le front n'a qu'une copie. Le hook useQuery charge une donnée, la met en cache, la partage entre les composants et la recharge quand elle devient périmée ; useMutation envoie une modification puis invalide le cache pour mettre l'écran à jour. Ce chapitre remplace le fetch dans un effet du chapitre 7 par TanStack Query 5.

État de l'interface, état du serveur

Les données d'une application React sont de deux natures :

État de l'interface (client state)État du serveur (server state)
ExemplesUn panneau déplié, la saisie d'un champ, le filtre choisiLes fiches, les frais, le profil de l'utilisateur
PropriétaireLe front, seulLa base de données ; le front n'en a qu'une copie
Peut devenir fauxNonOui : un autre utilisateur, un autre onglet, le comptable l'ont modifié
OutiluseState, Context (chapitre 11)Une bibliothèque de données serveur : TanStack Query

Gérer l'état du serveur avec useState, c'est réécrire à chaque fois le chargement, les erreurs, l'annulation, le cache, le rechargement… C'est le travail de TanStack Query.

Installer et configurer

bash
npm install @tanstack/react-query

On crée un client de requêtes pour toute l'application, et on le fournit à l'arbre des composants dans main.tsx :

tsx
// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ApiError } from './api/client';
import App from './App';
import './index.css';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,   // une donnée reste « fraîche » 30 secondes
      // Réessayer une panne (réseau, 5xx), jamais une erreur du client (404, 401…)
      retry: (echecs, erreur) => !(erreur instanceof ApiError && erreur.status < 500) && echecs < 3,
    },
  },
});

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>,
);

Les deux options réglées ici changent des valeurs par défaut :

  • staleTime vaut 0 par défaut : une donnée est considérée comme périmée dès qu'elle est reçue, et rechargée à chaque nouvel affichage d'un composant qui l'utilise. Trente secondes évitent des requêtes inutiles quand on navigue entre la liste et une fiche.
  • retry vaut 3 par défaut : en cas d'erreur, TanStack Query réessaie trois fois, en attendant de plus en plus longtemps (environ 1, 2 puis 4 secondes). C'est utile pour une coupure réseau, mais absurde pour un 404 : l'utilisateur attendrait sept secondes avant de voir « Aucune fiche 999 ». La fonction ne réessaie que les pannes, comme le recommande le cours API REST.

useQuery : lire une donnée

tsx
import { useQuery } from '@tanstack/react-query';
import { getFiches } from '../api/fiches';

const { data, isPending, isError, error } = useQuery({
  queryKey: ['fiches'],
  queryFn: ({ signal }) => getFiches(undefined, signal),
});

Deux options obligatoires :

  • queryKey : la clé de la donnée dans le cache, un tableau. Deux composants qui utilisent la même clé partagent la même donnée et la même requête.
  • queryFn : la fonction qui charge la donnée. Elle reçoit un signal d'annulation, qu'on transmet à fetch : TanStack Query annule lui-même les requêtes devenues inutiles.

Et ce que renvoie le hook :

PropriétéSens
dataLa donnée, undefined tant qu'elle n'est pas arrivée
isPendingAucune donnée encore disponible (premier chargement)
isError, errorLa dernière requête a échoué ; error est l'exception levée (notre ApiError)
isSuccessUne donnée est disponible
isFetchingUne requête est en cours, y compris un rechargement en arrière-plan alors que data est déjà là
refetch()Relance la requête (bouton « Réessayer »)

Des hooks par ressource

Comme pour les appels à l'API, on ne répète pas les clés et les fonctions dans chaque composant : on écrit un hook par ressource.

ts
// src/hooks/useFiches.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getFiche, getFiches, supprimerFrais } from '../api/fiches';
import type { EtatFiche } from '../types';

export function useFiches(etat?: EtatFiche) {
  return useQuery({
    queryKey: ['fiches', { etat }],
    queryFn: ({ signal }) => getFiches(etat, signal),
  });
}

export function useFiche(id: number) {
  return useQuery({
    queryKey: ['fiches', id],
    queryFn: ({ signal }) => getFiche(id, signal),
  });
}

export function useSupprimerFrais() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: supprimerFrais,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['fiches'] }),
  });
}

La clé contient tout ce dont dépend la requête : ['fiches', { etat: 'CL' }] et ['fiches', { etat: 'VA' }] sont deux entrées distinctes du cache. Quand le filtre change, la clé change et TanStack Query charge la nouvelle liste, exactement comme le tableau de dépendances d'un effet, mais sans que tu aies à gérer l'annulation ni la situation de compétition.

Les clés sont hiérarchiques : ['fiches'] est le préfixe de ['fiches', { etat: 'CL' }] et de ['fiches', 12]. On peut donc agir sur toutes les données des fiches d'un coup.

La page « Mes fiches » devient :

tsx
export function PageFiches() {
  const [etat, setEtat] = useState<EtatFiche | ''>('');
  const { data: fiches, isPending, isError, error, refetch } = useFiches(etat || undefined);

  return (
    <>
      <h1>Mes fiches de frais</h1>
      <FiltreEtat valeur={etat} onChange={setEtat} />
      {isPending ? (
        <p role="status">Chargement des fiches…</p>
      ) : isError ? (
        <div role="alert">
          <p>Impossible de charger les fiches : {error.message}</p>
          <button type="button" onClick={() => refetch()}>Réessayer</button>
        </div>
      ) : fiches.length === 0 ? (
        <p>Aucune fiche pour ce filtre.</p>
      ) : (
        <ul className="liste-fiches">
          {fiches.map((fiche) => (
            <li key={fiche.id}><CarteFiche fiche={fiche} /></li>
          ))}
        </ul>
      )}
    </>
  );
}

Plus d'effet, plus d'état pour le résultat, plus d'AbortController. Et TypeScript suit : après les tests isPending et isError, il sait que fiches est un FicheResume[], pas undefined. (etat || undefined transforme le choix « toutes les fiches », la chaîne vide, en absence de filtre.)

Ce que le cache change pour l'utilisateur

Ouvre la liste, puis une fiche, puis reviens à la liste :

  1. Première visite : pas de donnée en cache, « Chargement des fiches… », puis la liste.
  2. Retour sur la liste moins de 30 secondes plus tard : la donnée est fraîche, la liste s'affiche instantanément, sans requête.
  3. Retour après 30 secondes : la donnée est périmée. La liste en cache s'affiche instantanément, et une requête part en arrière-plan (isFetching vaut true). Si les données ont changé, l'écran se met à jour tout seul.

C'est la stratégie stale-while-revalidate : afficher tout de suite ce qu'on a, puis vérifier. TanStack Query recharge aussi les données périmées quand l'utilisateur revient sur l'onglet du navigateur et quand la connexion revient après une coupure. Les données inutilisées sont retirées du cache au bout de cinq minutes (gcTime).

composantsPageFichesuseFiches('CR')CompteuruseFiches('CR')le cache du QueryClient['fiches', { etat: 'CR' }]fraîche : servie sans requête['fiches', 12]périmée : rechargée au besoin['moi']fraîcheGETAPIAppliFraisUne seule requête GET /fiches?etat=CR pour les deux composants.useSupprimerFrais()mutate(58)DELETE /frais-hors-forfait/58API204onSuccess : invalidateQueries({ queryKey: ['fiches'] })Les entrées dont la clé commence par 'fiches' deviennent périmées ;celles qui sont affichées à l'écran se rechargent aussitôt.
Une clé, une entrée de cache partagée ; une mutation invalide le cache, qui se recharge

useMutation : modifier une donnée

Une mutation est une écriture : créer, modifier, supprimer. useMutation gère son exécution et son état :

tsx
// src/components/ListeFrais.tsx (extrait)
const suppression = useSupprimerFrais();

<button
  type="button"
  onClick={() => suppression.mutate(f.id)}
  disabled={suppression.isPending}
  aria-label={`Supprimer le frais « ${f.libelle} »`}
>
  Supprimer
</button>
{suppression.isError && <p role="alert">{suppression.error.message}</p>}
Propriété ou méthodeSens
mutate(variables)Lance la mutation ; les variables sont passées à mutationFn
mutateAsync(variables)Pareil, mais renvoie une promesse qu'on peut attendre avec await
isPendingLa mutation est en cours
isError, errorLa mutation a échoué
isSuccess, dataLa mutation a réussi, data est la réponse de l'API
reset()Efface l'erreur ou le résultat

Invalider le cache après une modification

Après la suppression d'un frais, le détail de la fiche est faux dans le cache (sa liste de frais, son total) ; après une clôture, ce sont le détail et les listes filtrées par état. Plutôt que de corriger chaque entrée une à une, on les déclare périmées :

ts
queryClient.invalidateQueries({ queryKey: ['fiches'] });

Toutes les entrées dont la clé commence par ['fiches'] deviennent périmées ; celles qui sont affichées à l'écran sont rechargées immédiatement, les autres le seront à leur prochain affichage. C'est ce que fait onSuccess dans useSupprimerFrais. Comme onSuccess renvoie la promesse de invalidateQueries, la mutation ne passe à « terminée » qu'une fois les données rechargées : le bouton reste désactivé jusqu'à ce que la liste à jour soit affichée.

Le formulaire d'ajout du chapitre 8 fait de même dans son Action, avec le client récupéré par useQueryClient() :

tsx
// src/components/FormulaireFrais.tsx (extrait)
const queryClient = useQueryClient();

async function enregistrer(_precedent: EtatFormulaire, formData: FormData): Promise<EtatFormulaire> {
  …
  try {
    await ajouterFrais(ficheId, saisie);
    await queryClient.invalidateQueries({ queryKey: ['fiches'] });   // la liste se recharge
    return { erreur: null, saisie: VIDE };
  } catch (e) {
    …
  }
}

Le formulaire garde useActionState (qui gère bien la saisie, la validation et l'état d'envoi) ; TanStack Query s'occupe du cache. Les deux outils se complètent.

Mettre à jour le cache directement

L'invalidation coûte une requête. Quand la réponse de l'API contient déjà la donnée à jour, comme la fiche détaillée que renvoie la clôture, on peut l'écrire dans le cache avec setQueryData :

ts
onSuccess: (ficheMiseAJour) => {
  queryClient.setQueryData(['fiches', ficheMiseAJour.id], ficheMiseAJour);
},

Ce n'est utile que si tu connais exactement la forme de la donnée en cache. En cas de doute, invalide : c'est plus simple et toujours juste. TanStack Query sait aussi faire des mises à jour optimistes (avec onMutate), sur le même principe que useOptimistic du chapitre 8.

Et le hook use() de React 19 ?

React 19 a ajouté use(promesse), qui lit la valeur d'une promesse en suspendant le composant jusqu'à ce qu'elle soit résolue ; un composant <Suspense fallback={…}> placé au-dessus affiche le message d'attente. Il est tentant d'écrire use(getFiches()) dans un composant : c'est une erreur, car chaque rendu crée une nouvelle promesse, donc une nouvelle requête, et le composant reste suspendu en boucle. La promesse passée à use doit être créée hors du rendu et mise en cache : c'est ce que font les frameworks (Next.js, React Router en mode framework) et TanStack Query, qui propose useSuspenseQuery pour ce style. Dans une application Vite comme AppliFrais, useQuery reste le plus simple.

Et l'état global : Redux, Zustand ?

Une fois les données serveur confiées à TanStack Query, il reste peu d'état vraiment global dans une application de gestion : l'utilisateur connecté, éventuellement un thème ou une langue. Le Context de React suffit (chapitre 11). Une bibliothèque d'état global comme Zustand ou Redux Toolkit se justifie pour un état d'interface riche et partagé par de nombreux écrans : un éditeur de documents, un panier complexe, un outil de dessin. Tu la croiseras en entreprise ; tu n'en as pas besoin pour AppliFrais.

Exercices

Exercice 1 sur 4 Choisir les clés Réussi À revoir

Propose une clé de requête pour chaque donnée, de façon qu'une invalidation de ['fiches'] les recharge toutes sauf la dernière.

  1. Toutes les fiches du visiteur connecté.
  2. Les fiches clôturées du visiteur.
  3. Le détail de la fiche 12.
  4. Le profil de l'utilisateur connecté.
Voir la réponseMasquer la réponse de l'exercice 1
  1. ['fiches', { etat: undefined }], tel que le produit useFiches() (ou simplement ['fiches']).
  2. ['fiches', { etat: 'CL' }].
  3. ['fiches', 12].
  4. ['moi'] : la clé ne commence pas par 'fiches', elle n'est pas touchée.

Tu avais trouvé ?

Exercice 2 sur 4 La mutation de clôture Réussi À revoir

Écris le hook useCloturerFiche() qui appelle cloturerFiche(id) (chapitre 7) et invalide les fiches en cas de succès. Puis écris le bouton qui l'utilise : il affiche « Clôture… » et se désactive pendant la requête, et affiche le message d'erreur de l'API en cas d'échec.

Voir la réponseMasquer la réponse de l'exercice 2
ts
export function useCloturerFiche() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: cloturerFiche,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['fiches'] }),
  });
}
tsx
function BoutonCloturer({ ficheId }: { ficheId: number }) {
  const cloture = useCloturerFiche();
  return (
    <>
      <button type="button" onClick={() => cloture.mutate(ficheId)} disabled={cloture.isPending}>
        {cloture.isPending ? 'Clôture…' : 'Clôturer la fiche'}
      </button>
      {cloture.isError && <p role="alert">{cloture.error.message}</p>}
    </>
  );
}

Après le succès, la fiche passe à l'état CL dans le détail et dans la liste, sans rien écrire de plus : les deux viennent de clés qui commencent par 'fiches'.

Tu avais trouvé ?

Exercice 3 sur 4 Diagnostiquer Réussi À revoir

Pour chaque symptôme, donne la cause probable.

  1. Après l'ajout d'un frais, le détail de la fiche ne montre pas le nouveau frais tant qu'on ne recharge pas la page.
  2. Le filtre change, mais la liste affichée reste la même.
  3. Une fiche inexistante affiche « Chargement… » pendant sept secondes avant le message d'erreur.
  4. L'onglet Réseau montre une requête vers /fiches chaque fois qu'on revient sur l'onglet du navigateur.
Voir la réponseMasquer la réponse de l'exercice 3
  1. L'ajout ne suit pas d'invalidation (invalidateQueries) ou invalide une autre clé que celle du détail.
  2. La clé de la requête ne contient pas le filtre : queryKey: ['fiches'] au lieu de ['fiches', { etat }]. TanStack Query croit que c'est la même donnée.
  3. L'option retry par défaut réessaie trois fois les erreurs, y compris un 404, avec des délais croissants (environ 1 + 2 + 4 secondes). Il faut ne pas réessayer les erreurs 4xx.
  4. C'est le comportement normal : une donnée périmée est rechargée au retour sur l'onglet (refetchOnWindowFocus). On peut allonger staleTime si les données changent rarement, ou désactiver l'option pour cette requête.

Tu avais trouvé ?

Exercice 4 sur 4 Avant, après Réussi À revoir

Compte, dans ListeFichesFetch (chapitre 7) et dans PageFiches (ce chapitre), le nombre de useState, de useEffect et de lignes consacrées au chargement. Cite trois comportements que la seconde version a et que la première n'a pas.

Voir la réponseMasquer la réponse de l'exercice 4

ListeFichesFetch : un seul useState (le résultat, qui regroupe les fiches, l'erreur et le filtre pour lequel il a été obtenu), un useEffect avec son AbortController, et une vingtaine de lignes pour le chargement, l'erreur et l'annulation. PageFiches : aucun useState pour les données (le seul useState est celui du filtre, un état d'interface), aucun useEffect, une ligne useFiches(…).

Comportements en plus : le cache (retour instantané sur la liste), le partage d'une même requête entre composants, le rechargement automatique au retour sur l'onglet, les nouvelles tentatives en cas de panne réseau, l'invalidation après une mutation, le bouton « Réessayer » avec refetch.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : React Router : navigation, routes et paramètres d'URLRevoir le sommaire du cours