TanStack Query : data fetching React moderne

Front-end 27/03/2026 14:00:00 angularforall.com
React Tanstack Query Usequery Usemutation Data Fetching
TanStack Query : data fetching React moderne

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
TanStack Query (anciennement React Query) résout tous ces problèmes avec une API de 3 hooks : 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>
);
Les DevTools TanStack Query affichent en temps réel l'état de chaque requête (fresh, stale, fetching, error), le contenu du cache, et permettent d'invalider manuellement les requêtes. Indispensable pendant le développement.

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>;
}
Règle d'or des query keys : Incluez dans la clé tout ce dont dépend la requête — les paramètres, les filtres, la page courante. TanStack Query refetche automatiquement quand la clé change. Si vous oubliez un paramètre dans la clé, vous verrez des données périmées.

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>
    );
}
Les 3 étapes des optimistic updates : (1) 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

  • QueryClientProvider enveloppe toute l'application dans main.tsx
  • La queryKey inclut tous les paramètres dont dépend la requête
  • La queryFn lance une throw si la réponse n'est pas OK (if (!res.ok) throw)
  • Les queries conditionnelles utilisent enabled: false au lieu de conditions dans la queryFn
  • Les mutations invalident le cache concerné dans onSuccess
  • Les hooks useQuery sont encapsulés dans des Custom Hooks (useArticles)
  • staleTime est 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 mutateAsync avec try/catch pour gérer les erreurs
Conclusion : TanStack Query transforme la façon dont vous pensez les données dans React. Fini les 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.

Partager