Accueil / Articles / TanStack Query pour React : mise en cache, rechargement et mutations

TanStack Query pour React : mise en cache, rechargement et mutations

Remplacez le code de base fetch d’useEffect par TanStack Query : clés de requête, staleTime, gcTime, mutations, ainsi que les cas où cette bibliothèque n’est pas nécessaire.

1840 mots

Comment l’état asynchrone du serveur est mémorisé en cache, mis à jour et refreshé — et quand il vaut la peine d’ajouter une bibliothèque.

De nombreuses applications React commencent avec la même structure de chargement des données : un useEffect, une requête fetch, ainsi que quelques variables useState pour gérer l’affichage des états en attente ou d’erreur. Ce schéma est adapté pour une première version. C’est aussi là que surviennent souvent des requêtes redondantes, des écrans obsolètes et du code générique copié-collé.

Les sections suivantes analysent ces lacunes et montrent comment TanStack Query les comble. Les seuls prérequis sont les hooks de React et des connaissances de base en TypeScript. Les exemples utilisent DummyJSON, une API publique qui ne nécessite pas de clé API.

1. Le schéma de base

Une liste de produits écrite avec les hooks habituels ressemble à ceci.

import { useEffect, useState } from "react";
type Product = {
  id: number;
  title: string;
  price: number;
};
function ProductList() {
  const [products, setProducts] = useState<Product[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  useEffect(() => {
    let cancelled = false;
    setIsLoading(true);
    fetch("https://dummyjson.com/products?limit=10")
      .then((res) => {
        if (!res.ok) throw new Error("Request failed");
        return res.json();
      })
      .then((data: { products: Product[] }) => {
        if (!cancelled) setProducts(data.products);
      })
      .catch((err: Error) => {
        if (!cancelled) setError(err.message);
      })
      .finally(() => {
        if (!cancelled) setIsLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, []);
  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>{error}</p>;
  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>
          {product.title} - ${product.price}
        </li>
      ))}
    </ul>
  );
}

Cette liste est déjà bien conçue : elle utilise un drapeau cancelled afin qu’une réponse lente ne puisse pas mettre à jour l’état après le démontage. De nombreux projets réels omettent cette protection.

2. Ce que ce code ne gère pas

La requête elle-même est correcte. Les problèmes se situent autour d’elle.

  • Aucun cache. En quittant la route et en revenant, l’appel réseau est exécuté à nouveau même lorsque le contenu n’a pas changé.
  • Aucune déduplication des requêtes. Trois composants ayant besoin de la même liste de produits envoient trois requêtes identiques.
  • Aucune tentative de réessai. Une seule connexion interrompue provoque une interface d’erreur, même lorsque une deuxième tentative réussirait.
  • Aucune révalidation. Une page laissée ouverte pendant une heure continue d’afficher des données obsolètes jusqu’à ce que quelque chose d’autre déclenche un chargement.
  • Conditions de concurrence. Lorsqu’une entrée telle qu’un terme de recherche change rapidement, une réponse plus ancienne peut écraser une réponse plus récente. Le drapeau cancelled aide à ce sujet, mais il ne résout pas entièrement le problème des requêtes en cours qui se chevauchent.
  • Redondance des éléments répétitifs. Les mêmes blocs de code pour le chargement, les erreurs et le nettoyage sont dupliqués dans chaque composant de chargement de données.
  • Chaque problème a une solution connue. Mettre en œuvre ces solutions manuellement signifie devoir créer soi-même une couche de mise en cache.

    3. L’idée derrière la bibliothèque

    Une distinction utile existe entre deux types d’état.

    L’état client appartient à l’interface utilisateur : le fait qu’un modal soit ouvert, le champ de formulaire actuel, le thème sélectionné. Il ne change que lorsque votre code le modifie. useState convient à cette tâche.

    État du serveur est emprunté. Il se trouve dans un stockage que vous ne contrôlez pas. D’autres utilisateurs peuvent le modifier, et la copie dans le navigateur n’est qu’un instantané. Conserver cet instantané uniquement dans useState fait croire qu’une vue temporaire est autoritaire.

    Les données empruntées nécessitent un stockage dédié : un endroit pour la copie, un indicateur de date d’expiration et une règle déterminant quand récupérer à nouveau les données.

    Un réfrigérateur constitue une analogie pertinente. Le lait reste à la maison afin de ne pas devoir aller au magasin pour chaque tasse de café, mais il expire, c’est pourquoi on vérifie la date et on le réapprovisionne avant qu’il ne se gâte. TanStack Query remplit cette fonction pour les réponses API.

    4. Qu’est-ce que TanStack Query

    TanStack Query gère l’état distant asynchrone dans les applications navigateur. Le projet est sous licence MIT, libre d’utilisation, et très répandu dans les projets React.

    Les guides écrits il y a des années mentionnent encore React Query. Cette appellation a persisté jusqu’à la version 3. La version 4 a renommé le projet TanStack Query après la sortie d’adaptateurs pour Vue, Svelte, Solid et Angular. Avec React, on installe toujours @tanstack/react-query ; la version 5 est actuellement la plus récente.

    Ce n’est pas un substitut à fetch ou axios. La fonction de requête reste sous votre contrôle. Autour de cette fonction, la bibliothèque gère le timing, le stockage, la fraîcheur des données et la gestion des erreurs.

    5. Configuration

    Deux étapes suffisent pour commencer.

    npm install @tanstack/react-query
    

    Puis il faut enrouler l’arborescence une seule fois à la racine :

    // main.tsx
    import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
    import App from "./App";
    
    const queryClient = new QueryClient();
    
    export default function Root() {
      return (
        <QueryClientProvider client={queryClient}>
          <App />
        </QueryClientProvider>
      );
    }
    

    QueryClient représente l’instance du cache. QueryClientProvider la met à disposition de chaque composant descendant.

    6. Le même composant, réécrit

    import { useQuery } from "@tanstack/react-query";
    
    type Product = {
      id: number;
      title: string;
      price: number;
    };
    
    async function fetchProducts(): Promise<Product[]> {
    
      const res = await fetch("https://dummyjson.com/products?limit=10");
    
      if (!res.ok) throw new Error("Request failed");
      const data: { products: Product[] } = await res.json();
      return data.products;
    }
    
    function ProductList() {
    
      const { data, isPending, isError, error } = useQuery({
        queryKey: ["products"],
        queryFn: fetchProducts,
      });
    
      if (isPending) return <p>Loading…</p>;
      if (isError) return <p>{error.message}</p>;
      return (
        <ul>
          {data.map((product) => (
            <li key={product.id}>
              {product.title} - ${product.price}
            </li>
          ))}
        </ul>
      );
    }
    

    Environ quarante lignes sont réduites à une quinzaine. La variable data est déclarée sous la forme Product[] sans annotation supplémentaire, car son type provient directement de fetchProducts. Après les branches isPending et isError, TypeScript considère que data est bien définie, ce qui évite l’utilisation de la chaînage optionnel sur map ainsi que toute assertion non nulle.

    7. Ce que vous gagnez avec la version simplifiée

    Par rapport aux lacunes mentionnées dans la section 2, les valeurs par défaut couvrent déjà les cas courants :

    • Remount affiche immédiatement le chargement en mémoire et le valide à nouveau en arrière-plan.
    • Les requêtes en cours identiques sont regroupées en une seule demande.
    • Les requêtes échouées sont réessayées automatiquement (trois tentatives par défaut, avec retards progressifs).
    • Les entrées obsolètes sont rechargées lorsque la fenêtre devient active, que le réseau se reconnecte ou que Remount est relancé.
  • Seule la dernière réponse pour une clé est enregistrée dans le cache, ce qui limite les problèmes liés aux concurrences.
  • Un mécanisme unique remplace le chargement manuel et les états d’erreur.
  • Aucun de ces comportements n’a été configuré dans la composante réécrite ; il s’agit des paramètres par défaut de la bibliothèque.

    8. Trois points à comprendre

    La plupart des confusions initiales proviennent des trois idées suivantes.

    La clé de requête

    queryKey correspond à l’adresse du cache. Deux composants utilisant tous deux ["products"] partagent une même entrée ainsi qu’une seule requête réseau.

    Règle générale : toute valeur sur laquelle dépend la fonction de requête doit figurer dans la clé.

    function ProductList({ category }: { category: string }) {
      const { data } = useQuery({
        queryKey: ["products", category],
        queryFn: () => fetchProductsByCategory(category),
      });
      // …
    }
    

    Si category est omis de la clé, modifier la catégorie peut encore afficher la liste en mémoire cache de la catégorie précédente. Cette erreur est extrêmement fréquente chez les utilisateurs débutants.

    staleTime et gcTime

    Ces noms se ressemblent mais ont des significations différentes.

    staleTime définit la fenêtre de fraîcheur. Au sein de cette fenêtre, la bibliothèque évite les opérations réseau. Avec une valeur par défaut de 0, un résultat devient immédiatement obsolète : l’interface peut toujours afficher la valeur en mémoire tampon, mais tout déclencheur planifie un renouvellement en arrière-plan. Augmentez cette valeur lorsque le contenu mis à jour change rarement :

    useQuery({
      queryKey: ["products"],
      queryFn: fetchProducts,
      staleTime: 5 * 60 * 1000, // fresh for five minutes
    });
    

    gcTime indique combien de temps les données non utilisées restent en mémoire après que le dernier abonné a quitté l’application. La valeur par défaut est de cinq minutes. Lorsque cette période s’achève, l’entrée est supprimée et la prochaine consultation démarre à partir de zéro.

    En bref : staleTime gère le renouvellement des données ; gcTime gère leur suppression.

    Lorsque a lieu le renouvellement

    Par défaut, une requête obsolète est rechargée lorsque un composant est monté, lorsque la fenêtre redevient en surbrillance et lorsque le réseau se reconnecte. Chacun de ces comportements peut être désactivé au niveau du client ou pour une requête spécifique :

    useQuery({
      queryKey: ["products"],
      queryFn: fetchProducts,
      refetchOnWindowFocus: false,
    });
    

    Le rechargement en cas de surbrillance peut surprendre les utilisateurs la première fois qu’ils le voient. Le garder activé est généralement la raison pour laquelle une page ouverte depuis longtemps reste à jour.

    9. Modifier les données avec useMutation

    useQuery sert à lire. useMutation sert à écrire.

    import { useMutation, useQueryClient } from "@tanstack/react-query";
    
    type NewProduct = {
      title: string;
      price: number;
    };
    
    function AddProductButton() {
    
      const queryClient = useQueryClient();
    
      const { mutate, isPending } = useMutation({
    
        mutationFn: async (product: NewProduct) => {
          const res = await fetch("https://dummyjson.com/products/add", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(product),
          });
    
          if (!res.ok) throw new Error("Could not add product");
          return res.json();
        },
    
        onSuccess: () => {
          queryClient.invalidateQueries({ queryKey: ["products"] });
        },
      });
    
      return (
        <button
          onClick={() => mutate({ title: "New product", price: 25 })}
          disabled={isPending}
        >
          {isPending ? "Saving…" : "Add product"}
        </button>
      );
    }
    

    La ligne importante est invalidateQueries. Cette fonction marque toutes les entrées du cache portant le préfixe ["products"] comme obsolètes, ce qui pousse les observateurs montés à demander immédiatement de nouvelles données. Les tableaux locaux ne sont pas modifiés manuellement, et la page n’a pas besoin d’être entièrement rechargée.

    10. Les outils de développement

    npm install @tanstack/react-query-devtools
    
    import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
    
    <QueryClientProvider client={queryClient}>
      <App />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
    

    Pendant le développement, un panneau affiche chaque clé de requête, son statut, sa charge utile et l’heure de la dernière récupération. Observer les entrées passer du statut « frais » à « obsolète » au fur et à mesure que vous naviguez permet de comprendre le modèle de cache plus rapidement qu’en se contentant de lire des informations. Le paquet est automatiquement retiré des versions en production.

    Erreurs courantes

    • Omettre des variables dans la clé de requête. Si la fonction de requête lit une valeur, cette dernière doit être incluse dans la clé.
    • Placer useQuery à l’intérieur d’un useEffect. Le hook s’exécute déjà lors du rendu ; il n’y a rien de supplémentaire à « déclencher ».
    • Utiliser la bibliothèque pour gérer uniquement l’état côté client. Les champs de formulaire et les indicateurs de modaux doivent être gérés avec useState.
    • Définir staleTime: Infinity partout. Cela désactive la révalidation, ce qui annule la plupart des avantages.
  • Copier data dans l’état local. Vous conservez ainsi deux copies, et celle qui est affichée cesse de suivre le cache.
  • 12. Lorsque vous n’en avez pas besoin

    Si l’application accède à un seul point de terminaison sur une seule page, l’utilisation de fournisseurs et d’hooks peut représenter plus de formalités que le problème ne l’exige.

    Si le framework fournit déjà une couche de données — des composants serveur Next.js ou un routeur avec des chargeurs — une partie du travail est déjà accomplie. TanStack Query peut encore être utile pour les requêtes interactives côté client, mais ce n’est pas obligatoire.

    Pour un état qui ne quitte jamais le navigateur, choisissez un outil différent.

    13. Où aller ensuite

    Les tâches quotidiennes sont couvertes par les concepts mentionnés ci-dessus. Les sujets plus avancés se trouvent ailleurs :

    • Documentation officielle — matériel de référence et démos interactives
    • Listes paginées et infinies — utilisez useInfiniteQuery pour charger davantage de pages lors du défilement
    • Consultes qui attendent d’autres opérations — restreignez une demande ultérieure avec enabled jusqu’à ce que la condition préalable soit remplie
    • UI optimiste — affichez le résultat attendu avant que la réponse de la mutation ne revienne

    Gardez à l’esprit ce principe fondamental : les données distantes doivent être stockées dans un cache, et non dans l’état d’un composant spécifique. Avec ce modèle en tête, le reste de l’API devient plus logique.