Maîtrisez TanStack Query (React Query) pour fetcher, mettre en cache et synchroniser vos données serveur dans React. Guide complet avec exemples pratiques.
Le problème du data fetching manuel
Chaque développeur React débutant écrit un jour ce pattern dans un useEffect : fetch les données, gérer le chargement, gérer l'erreur, stocker dans un state. Simple au départ, ce code devient rapidement un problème de maintenance à grande échelle.
// Le pattern manuel que tout le monde connaît — et ses limites
function ListeUtilisateurs() {
const [utilisateurs, setUtilisateurs] = useState([]);
const [chargement, setChargement] = useState(true);
const [erreur, setErreur] = useState(null);
useEffect(() => {
setChargement(true);
fetch('/api/utilisateurs')
.then(r => r.json())
.then(data => { setUtilisateurs(data); setChargement(false); })
.catch(err => { setErreur(err.message); setChargement(false); });
}, []);
// Ce code de 15 lignes sera recopié dans CHAQUE composant qui fetch
// Il manque : cache, refetch auto, retry, deduplication des requêtes...
}
Ce pattern présente de nombreuses lacunes en production :
- Pas de cache : naviguer entre deux pages re-fetche les mêmes données à chaque fois
- Pas de deduplication : deux composants sur la même page qui fetche la même URL font deux requêtes réseau
- Pas de retry automatique : une erreur réseau temporaire reste une erreur définitive
- Données périmées : les données de 10 minutes ago s'affichent encore alors qu'elles ont changé côté serveur
- Race conditions : deux requêtes simultanées peuvent arriver dans le mauvais ordre
useQuery, useMutation et useQueryClient. C'est aujourd'hui la bibliothèque de data fetching la plus téléchargée de l'écosystème React (~10M téléchargements/semaine).
| Fonctionnalité | useEffect manuel | TanStack Query |
|---|---|---|
| Cache automatique | ❌ | ✅ par clé de requête |
| Deduplication des requêtes | ❌ | ✅ natif |
| Retry sur erreur | ❌ manuel | ✅ 3 fois par défaut |
| Refetch au focus fenêtre | ❌ | ✅ configurable |
| Données périmées (stale) | ❌ | ✅ staleTime configurable |
| Pagination / infinite scroll | ❌ complexe | ✅ hooks dédiés |
| Optimistic updates | ❌ très complexe | ✅ pattern intégré |
| DevTools | ❌ | ✅ extension dédiée |
Installation et configuration
npm install @tanstack/react-query
# DevTools (optionnel mais très recommandé en développement)
npm install @tanstack/react-query-devtools
Configurer le QueryClient et le Provider
// main.tsx — configuration à la racine de l'application
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// QueryClient est le cerveau de TanStack Query
// Il gère le cache, les options globales et les abonnements
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// Durée pendant laquelle les données sont considérées fraîches
// 0 = re-fetch à chaque montage de composant (par défaut)
staleTime: 1000 * 60 * 5, // 5 minutes
// Durée de conservation en cache après que le composant est démonté
gcTime: 1000 * 60 * 10, // 10 minutes (anciennement cacheTime)
// Nombre de retry sur erreur réseau (3 par défaut)
retry: 2,
// Refetch quand la fenêtre reprend le focus (utile pour les données temps réel)
refetchOnWindowFocus: true,
},
mutations: {
retry: 0, // Ne pas réessayer les mutations automatiquement
},
},
});
createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<App />
{/* DevTools flottants — visibles uniquement en développement */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
useQuery : récupérer des données
useQuery est le hook principal pour lire des données. Il prend une clé de requête (query key) et une fonction de fetch, et retourne l'état complet de la requête.
Syntaxe de base
import { useQuery } from '@tanstack/react-query';
// Fonction de fetch — peut utiliser fetch, axios, ou n'importe quelle Promise
const fetchUtilisateurs = async (): Promise<Utilisateur[]> => {
const response = await fetch('/api/utilisateurs');
if (!response.ok) throw new Error(`HTTP ${response.status}`); // Important !
return response.json();
};
function ListeUtilisateurs() {
const {
data, // Les données retournées (undefined pendant le chargement)
isLoading, // true pendant le premier chargement (pas de données en cache)
isFetching, // true pendant n'importe quel fetch (y compris refetch)
isError, // true si la requête a échoué
error, // L'objet Error si isError est true
refetch, // Fonction pour déclencher un refetch manuel
} = useQuery({
queryKey: ['utilisateurs'], // Clé unique — identifie cette requête dans le cache
queryFn: fetchUtilisateurs, // Fonction qui retourne une Promise
});
if (isLoading) return <p>Chargement...</p>;
if (isError) return <p>Erreur : {(error as Error).message}</p>;
return (
<div>
<button onClick={() => refetch()}>Actualiser</button>
<ul>
{data?.map(u => <li key={u.id}>{u.nom}</li>)}
</ul>
</div>
);
}
Query Key : la clé du cache
La queryKey est le mécanisme fondamental de TanStack Query. C'est un tableau unique qui identifie une requête dans le cache. Quand la clé change, une nouvelle requête est déclenchée.
// Clé simple — identifie une liste globale
useQuery({ queryKey: ['utilisateurs'], queryFn: fetchUtilisateurs });
// Clé avec paramètre — une entrée de cache différente par ID
useQuery({
queryKey: ['utilisateurs', userId], // ['utilisateurs', 42]
queryFn: () => fetchUtilisateur(userId),
});
// Clé avec filtres — une entrée de cache par combinaison de filtres
useQuery({
queryKey: ['utilisateurs', { statut, page, recherche }],
queryFn: () => fetchUtilisateurs({ statut, page, recherche }),
});
// Exemple concret : page de détail d'un article
function DetailArticle({ articleId }: { articleId: number }) {
const { data: article, isLoading } = useQuery({
queryKey: ['articles', articleId], // Cache séparé pour chaque article
queryFn: async () => {
const res = await fetch(`/api/articles/${articleId}`);
if (!res.ok) throw new Error('Article introuvable');
return res.json();
},
// Paramètre important : ne pas lancer la requête si articleId est invalide
enabled: articleId > 0,
});
if (isLoading) return <p>Chargement de l'article...</p>;
return <h1>{article?.titre}</h1>;
}
useQuery avancé : options et comportements
Contrôler le cycle de vie du cache
function ListeArticles({ categorie }: { categorie: string }) {
const { data } = useQuery({
queryKey: ['articles', categorie],
queryFn: () => fetchArticles(categorie),
// --- Options de timing ---
// Données fraîches pendant 2 minutes — pas de refetch pendant ce temps
staleTime: 1000 * 60 * 2,
// Garder en cache 15 min après que le composant est démonté
gcTime: 1000 * 60 * 15,
// Intervalle de refetch automatique (polling) — pratique pour les données temps réel
refetchInterval: 1000 * 30, // Toutes les 30 secondes
// Arrêter le polling si la fenêtre n'est pas au premier plan
refetchIntervalInBackground: false,
// --- Options de comportement ---
// Ne pas re-fetch au montage si les données sont déjà dans le cache
refetchOnMount: false,
// Ne pas re-fetch quand la fenêtre reprend le focus
refetchOnWindowFocus: false,
// Nombre de tentatives en cas d'erreur
retry: 3,
// Délai entre les tentatives (exponentiel par défaut)
retryDelay: (tentative) => Math.min(1000 * 2 ** tentative, 30000),
});
return <ul>{data?.map(a => <li key={a.id}>{a.titre}</li>)}</ul>;
}
enabled : requête conditionnelle
function ProfilUtilisateur({ userId }: { userId: number | null }) {
// Requête désactivée si userId est null — pas de fetch inutile
const { data: profil } = useQuery({
queryKey: ['profil', userId],
queryFn: () => fetchProfil(userId!),
enabled: userId !== null, // Lance la requête seulement si userId existe
});
// Cas réel : charger les commandes SEULEMENT après avoir chargé l'utilisateur
const { data: commandes } = useQuery({
queryKey: ['commandes', profil?.id],
queryFn: () => fetchCommandes(profil!.id),
enabled: !!profil, // Lance uniquement quand profil est disponible
});
return <div>{profil?.nom} — {commandes?.length} commande(s)</div>;
}
select : transformer les données du cache
function NombreArticlesActifs() {
const { data: nombreActifs } = useQuery({
queryKey: ['articles'],
queryFn: fetchTousLesArticles,
// select transforme les données APRÈS réception, sans modifier le cache
// Plusieurs composants peuvent utiliser le même cache avec des transformations différentes
select: (articles) => articles.filter(a => a.actif).length,
});
return <p>{nombreActifs} article(s) actif(s)</p>;
}
// Autre composant, même cache ['articles'], transformation différente
function ListeArticlesPremium() {
const { data: articlesPremium } = useQuery({
queryKey: ['articles'],
queryFn: fetchTousLesArticles,
select: (articles) => articles.filter(a => a.premium),
});
return <ul>{articlesPremium?.map(a => <li key={a.id}>{a.titre}</li>)}</ul>;
}
// Les deux partagent le même fetch réseau et le même cache !
Créer un Custom Hook avec useQuery
// hooks/useArticles.ts — encapsuler useQuery dans un hook réutilisable
import { useQuery } from '@tanstack/react-query';
interface FiltresArticles {
categorie?: string;
page?: number;
recherche?: string;
}
export function useArticles(filtres: FiltresArticles = {}) {
return useQuery({
// La clé inclut tous les filtres actifs
queryKey: ['articles', filtres],
queryFn: async () => {
const params = new URLSearchParams();
if (filtres.categorie) params.set('categorie', filtres.categorie);
if (filtres.page) params.set('page', String(filtres.page));
if (filtres.recherche) params.set('q', filtres.recherche);
const res = await fetch(`/api/articles?${params}`);
if (!res.ok) throw new Error('Erreur lors du chargement des articles');
return res.json();
},
staleTime: 1000 * 60 * 2,
});
}
// Utilisation propre dans n'importe quel composant
function PageBlog() {
const { data, isLoading, isError } = useArticles({ categorie: 'react', page: 1 });
// ...
}
useMutation : créer, modifier, supprimer
useMutation gère les opérations qui modifient des données côté serveur : POST, PUT, PATCH, DELETE. Contrairement à useQuery, les mutations ne s'exécutent pas automatiquement — vous les déclenchez manuellement avec mutate() ou mutateAsync().
import { useMutation, useQueryClient } from '@tanstack/react-query';
function FormulaireNouvelArticle() {
const queryClient = useQueryClient();
const mutation = useMutation({
// La fonction qui effectue la mutation
mutationFn: async (nouvelArticle: { titre: string; contenu: string }) => {
const response = await fetch('/api/articles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(nouvelArticle),
});
if (!response.ok) throw new Error('Échec de la création');
return response.json(); // Retourne l'article créé avec son id
},
// Callbacks de cycle de vie
onSuccess: (articleCree) => {
// Invalider le cache 'articles' → re-fetch automatique
queryClient.invalidateQueries({ queryKey: ['articles'] });
console.log('Article créé :', articleCree.titre);
},
onError: (erreur: Error) => {
console.error('Erreur création :', erreur.message);
},
onSettled: () => {
// Exécuté que ce soit un succès ou une erreur
// Idéal pour arrêter un spinner ou réinitialiser un formulaire
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
// Déclencher la mutation avec les données du formulaire
mutation.mutate({
titre: formData.get('titre') as string,
contenu: formData.get('contenu') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="titre" placeholder="Titre" required />
<textarea name="contenu" placeholder="Contenu" required />
{/* Feedback visuel selon l'état de la mutation */}
{mutation.isError && (
<p style={{ color: 'red' }}>{(mutation.error as Error).message}</p>
)}
{mutation.isSuccess && <p style={{ color: 'green' }}>Article créé !</p>}
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Création...' : 'Créer l\'article'}
</button>
</form>
);
}
mutateAsync : attendre le résultat
function BoutonSupprimer({ articleId }: { articleId: number }) {
const queryClient = useQueryClient();
const { mutateAsync: supprimer, isPending } = useMutation({
mutationFn: (id: number) =>
fetch(`/api/articles/${id}`, { method: 'DELETE' }).then(r => {
if (!r.ok) throw new Error('Suppression échouée');
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articles'] });
},
});
const handleSupprimer = async () => {
const confirme = window.confirm('Supprimer cet article ?');
if (!confirme) return;
try {
// mutateAsync retourne une Promise — on peut await et gérer les erreurs
await supprimer(articleId);
alert('Article supprimé avec succès');
} catch (err) {
alert(`Erreur : ${(err as Error).message}`);
}
};
return (
<button onClick={handleSupprimer} disabled={isPending}>
{isPending ? 'Suppression...' : 'Supprimer'}
</button>
);
}
Invalidation et synchronisation du cache
L'invalidation du cache est le mécanisme qui force TanStack Query à re-fetcher des données après une mutation. C'est la clé pour maintenir l'interface synchronisée avec le serveur sans recharger toute la page.
import { useQueryClient } from '@tanstack/react-query';
function ExemplesInvalidation() {
const queryClient = useQueryClient();
// --- Invalider une requête précise ---
queryClient.invalidateQueries({ queryKey: ['articles'] });
// → Toutes les queries dont la clé commence par 'articles' sont invalidées
// → ['articles'], ['articles', { page: 1 }], ['articles', 42] — toutes re-fetchées
// --- Invalider une requête avec clé exacte ---
queryClient.invalidateQueries({
queryKey: ['articles'],
exact: true, // Seulement ['articles'], pas ['articles', 42]
});
// --- Mettre à jour le cache directement (sans re-fetch) ---
queryClient.setQueryData(['articles', articleId], (ancienneData: Article) => ({
...ancienneData,
titre: 'Nouveau titre', // Modifier directement le cache
}));
// --- Pré-remplir le cache (prefetching) ---
await queryClient.prefetchQuery({
queryKey: ['articles', prochainId],
queryFn: () => fetchArticle(prochainId),
});
// Les données sont déjà dans le cache quand l'utilisateur navigue
// --- Lire le cache sans re-fetch ---
const articlesEnCache = queryClient.getQueryData<Article[]>(['articles']);
}
Stratégie d'invalidation après mutation CRUD
// hooks/useArticlesMutations.ts — mutations groupées avec stratégies d'invalidation
export function useCreerArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: NouvelArticle) => postArticle(data),
onSuccess: (articleCree) => {
// Invalider la liste → re-fetch pour inclure le nouvel article
queryClient.invalidateQueries({ queryKey: ['articles'] });
},
});
}
export function useModifierArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<Article> }) =>
patchArticle(id, data),
onSuccess: (articleModifie) => {
// Mettre à jour le détail en cache directement — plus rapide qu'un re-fetch
queryClient.setQueryData(['articles', articleModifie.id], articleModifie);
// Invalider la liste car le titre ou les métadonnées ont peut-être changé
queryClient.invalidateQueries({ queryKey: ['articles'] });
},
});
}
export function useSupprimerArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => deleteArticle(id),
onSuccess: (_, id) => {
// Retirer l'article du cache de la liste sans re-fetch
queryClient.setQueryData<Article[]>(['articles'], ancienne =>
ancienne?.filter(a => a.id !== id) ?? []
);
// Supprimer le cache du détail devenu obsolète
queryClient.removeQueries({ queryKey: ['articles', id] });
},
});
}
Patterns avancés : pagination, infinite scroll
Pagination classique avec useQuery
function ListePaginee() {
const [page, setPage] = useState(1);
const { data, isLoading, isPlaceholderData } = useQuery({
queryKey: ['articles', page],
queryFn: () => fetchArticles(page),
// placeholderData : garder les données de la page précédente
// pendant le chargement de la nouvelle page (pas de flash blanc)
placeholderData: (ancienneData) => ancienneData,
});
// Pré-fetcher la page suivante en background
const queryClient = useQueryClient();
useEffect(() => {
if (data?.hasNextPage) {
queryClient.prefetchQuery({
queryKey: ['articles', page + 1],
queryFn: () => fetchArticles(page + 1),
});
}
}, [data, page, queryClient]);
return (
<div>
<ul>{data?.items.map(a => <li key={a.id}>{a.titre}</li>)}</ul>
{/* isPlaceholderData = true quand on affiche la page précédente */}
{isPlaceholderData && <p>Chargement de la page {page}...</p>}
<div>
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
← Précédent
</button>
<span> Page {page} </span>
<button
onClick={() => setPage(p => p + 1)}
disabled={!data?.hasNextPage || isPlaceholderData}
>
Suivant →
</button>
</div>
</div>
);
}
Infinite Scroll avec useInfiniteQuery
import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
function FeedInfini() {
const observeurRef = useRef<HTMLDivElement>(null);
const {
data,
fetchNextPage, // Charger la page suivante
hasNextPage, // true si une page suivante existe
isFetchingNextPage, // true pendant le chargement de la prochaine page
isLoading,
} = useInfiniteQuery({
queryKey: ['feed'],
queryFn: async ({ pageParam }) => {
// pageParam = valeur retournée par getNextPageParam de la page précédente
const res = await fetch(`/api/feed?cursor=${pageParam}`);
return res.json(); // { items: [...], nextCursor: 'abc123' }
},
// Point de départ
initialPageParam: '',
// Dire à TanStack Query comment obtenir le paramètre de la page suivante
getNextPageParam: (dernierePage) => dernierePage.nextCursor || undefined,
});
// Intersection Observer pour charger automatiquement la page suivante
useEffect(() => {
const observeur = new IntersectionObserver(
(entrees) => {
if (entrees[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage(); // Charger la prochaine page quand on atteint le bas
}
},
{ threshold: 0.1 }
);
if (observeurRef.current) observeur.observe(observeurRef.current);
return () => observeur.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading) return <p>Chargement...</p>;
return (
<div>
{/* data.pages = tableau de pages, chaque page a ses items */}
{data?.pages.map((page, i) => (
<div key={i}>
{page.items.map((item: any) => (
<div key={item.id} className="card mb-2">
{item.titre}
</div>
))}
</div>
))}
{/* Élément sentinelle — déclenche le chargement quand visible */}
<div ref={observeurRef}>
{isFetchingNextPage && <p>Chargement de la suite...</p>}
{!hasNextPage && <p>Fin du feed.</p>}
</div>
</div>
);
}
Mises à jour optimistes
Une mise à jour optimiste met à jour l'interface immédiatement avant même que le serveur ait répondu, puis corrige si le serveur retourne une erreur. Cela donne une sensation de rapidité exceptionnelle, notamment pour les toggles, les likes, et les suppressions.
function BoutonLike({ articleId, likesInitiaux }: { articleId: number; likesInitiaux: number }) {
const queryClient = useQueryClient();
const { mutate: toggleLike } = useMutation({
mutationFn: () => fetch(`/api/articles/${articleId}/like`, { method: 'POST' }).then(r => r.json()),
// onMutate s'exécute AVANT l'appel API — c'est ici qu'on fait la mise à jour optimiste
onMutate: async () => {
// Annuler les refetch en cours pour éviter qu'ils écrasent notre mise à jour
await queryClient.cancelQueries({ queryKey: ['articles', articleId] });
// Sauvegarder les données actuelles pour pouvoir les restaurer en cas d'erreur
const ancienneData = queryClient.getQueryData<Article>(['articles', articleId]);
// Mettre à jour le cache optimistement (sans attendre le serveur)
queryClient.setQueryData<Article>(['articles', articleId], (ancien) =>
ancien ? { ...ancien, likes: ancien.likes + 1, liked: true } : ancien
);
// Retourner le contexte pour le rollback éventuel
return { ancienneData };
},
// onError : le serveur a retourné une erreur — on restaure les données
onError: (err, variables, contexte) => {
if (contexte?.ancienneData) {
queryClient.setQueryData(['articles', articleId], contexte.ancienneData);
}
},
// onSettled : toujours re-valider pour être sûr d'être synchro avec le serveur
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['articles', articleId] });
},
});
const article = queryClient.getQueryData<Article>(['articles', articleId]);
return (
<button onClick={() => toggleLike()}>
{article?.liked ? '❤️' : '🤍'} {article?.likes ?? likesInitiaux}
</button>
);
}
onMutate : sauvegarder l'ancienne valeur, mettre à jour le cache immédiatement.
(2) onError : restaurer l'ancienne valeur si le serveur rejette.
(3) onSettled : invalider pour re-synchroniser avec le vrai état serveur.
Checklist TanStack Query
-
QueryClientProviderenveloppe toute l'application dansmain.tsx - La
queryKeyinclut tous les paramètres dont dépend la requête - La
queryFnlance unethrowsi la réponse n'est pas OK (if (!res.ok) throw) - Les queries conditionnelles utilisent
enabled: falseau lieu de conditions dans la queryFn - Les mutations invalident le cache concerné dans
onSuccess - Les hooks
useQuerysont encapsulés dans des Custom Hooks (useArticles) -
staleTimeest configuré selon la fréquence de mise à jour des données - Les DevTools (
ReactQueryDevtools) sont installés pour le debugging - TanStack Query gère l'état serveur — Zustand/Context gèrent l'état UI client
- Les mutations critiques utilisent
mutateAsyncavec try/catch pour gérer les erreurs
useEffect pour fetcher, les états chargement/erreur dupliqués partout, et les données périmées silencieuses. Avec useQuery pour lire, useMutation pour écrire, et useQueryClient pour synchroniser, vous obtenez une couche de cache serveur robuste, auto-gérée et debuggable en quelques dizaines de lignes. C'est l'un des meilleurs investissements d'apprentissage de l'écosystème React moderne.