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.
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.
cancelled aide à ce sujet, mais il ne résout pas entièrement le problème des requêtes en cours qui se chevauchent.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é.
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’unuseEffect. 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: Infinitypartout. Cela désactive la révalidation, ce qui annule la plupart des avantages.
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
useInfiniteQuerypour charger davantage de pages lors du défilement - Consultes qui attendent d’autres opérations — restreignez une demande ultérieure avec
enabledjusqu’à 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.