← Retour aux articles
Ecosystem TanStack QueryReactTypeScript

TanStack Query : le state serveur enfin maîtrisé

· 6 min de lecture

Fetcher des données en React a toujours été laborieux. On écrit un useEffect, on gère les états de chargement et d’erreur manuellement, on oublie d’annuler les requêtes périmées, et on se retrouve avec des composants dont 60% du code est du boilerplate de data-fetching. Pire, on stocke les réponses serveur dans des state managers globaux comme Redux, en traitant les données distantes comme un simple toggle d’UI. TanStack Query (anciennement React Query) trace une ligne nette entre le state serveur et le state client, et gère le premier avec du cache automatique, du refetching en arrière-plan, et une sémantique stale-while-revalidate qui prendrait des centaines de lignes à reproduire à la main.

Queries : fetcher des données sans la cérémonie

Une query se définit par une clé unique et une fonction asynchrone qui retourne des données. TanStack Query gère tout le reste : états de chargement, états d’erreur, cache, déduplication des requêtes concurrentes, et garbage collection automatique quand les données ne sont plus nécessaires.

import { useQuery } from '@tanstack/react-query';

interface Product {
  id: string;
  name: string;
  price: number;
}

export function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: async (): Promise<Product[]> => {
      const res = await fetch('/api/products');
      if (!res.ok) throw new Error('Failed to fetch products');
      return res.json();
    },
  });
}
function ProductList() {
  const { data, isLoading, error } = useProducts();

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorMessage error={error} />;

  return (
    <ul>
      {data.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

Si deux composants montent en même temps et appellent tous les deux useProducts, une seule requête réseau part. Les deux composants reçoivent les mêmes données en cache. Quand l’utilisateur navigue ailleurs puis revient, TanStack Query sert le cache périmé instantanément tout en refetchant en arrière-plan.

Query keys : le système d’identité du cache

Les query keys sont des tableaux qui identifient de manière unique une donnée serveur. Elles déterminent quand les données en cache sont réutilisées et quand un nouveau fetch est déclenché.

export function useProduct(id: string) {
  return useQuery({
    queryKey: ['products', id],
    queryFn: () => fetchProduct(id),
  });
}
export function useFilteredProducts(filters: ProductFilters) {
  return useQuery({
    queryKey: ['products', 'list', filters],
    queryFn: () => fetchProducts(filters),
  });
}

Changer l’id ou l’objet filters produit une nouvelle entrée de cache. Les entrées précédentes restent en cache pendant le temps de garbage collection configuré, ce qui rend la navigation vers un produit déjà consulté instantanée.

Mutations : écrire des données et garder le cache synchronisé

Les mutations gèrent les opérations de création, mise à jour et suppression. La vraie puissance vient des callbacks onSuccess qui invalident les queries liées, gardant l’UI synchronisée sans logique de refetch manuelle éparpillée dans les composants.

import { useMutation, useQueryClient } from '@tanstack/react-query';

export function useCreateProduct() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (newProduct: CreateProductInput) => {
      const res = await fetch('/api/products', {
        method: 'POST',
        body: JSON.stringify(newProduct),
      });
      return res.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] });
    },
  });
}

Appeler invalidateQueries marque toutes les queries commençant par ['products'] comme périmées. Tout composant monté qui consomme ces données refetch automatiquement. Pas d’event bus, pas de mise à jour manuelle du store, pas de dispatch d’actions pour synchroniser l’état.

Mises à jour optimistes pour des UIs réactives

Quand l’utilisateur attend un feedback immédiat, attendre l’aller-retour serveur semble poussif. Les mises à jour optimistes appliquent le changement dans le cache avant que la mutation ne se termine, puis font un rollback en cas d’échec.

export function useToggleFavorite() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (productId: string) => api.toggleFavorite(productId),
    onMutate: async (productId) => {
      await queryClient.cancelQueries({ queryKey: ['products', productId] });
      const previous = queryClient.getQueryData<Product>(['products', productId]);

      queryClient.setQueryData<Product>(['products', productId], (old) =>
        old ? { ...old, isFavorite: !old.isFavorite } : old
      );

      return { previous };
    },
    onError: (_err, productId, context) => {
      queryClient.setQueryData(['products', productId], context?.previous);
    },
    onSettled: (_data, _err, productId) => {
      queryClient.invalidateQueries({ queryKey: ['products', productId] });
    },
  });
}

Le callback onMutate sauvegarde les données actuelles, applique le changement optimiste, et retourne le snapshot. Si la mutation échoue, onError restaure l’état précédent. onSettled refetch toujours pour s’assurer que le cache correspond au serveur, que la mutation ait réussi ou non.

Queries parallèles et dépendantes

Les sources de données indépendantes doivent être fetchées en parallèle. TanStack Query rend ce comportement par défaut quand on utilise plusieurs hooks useQuery dans le même composant.

function Dashboard() {
  const products = useQuery({ queryKey: ['products'], queryFn: fetchProducts });
  const analytics = useQuery({ queryKey: ['analytics'], queryFn: fetchAnalytics });
  const notifications = useQuery({
    queryKey: ['notifications'],
    queryFn: fetchNotifications,
  });

  // Les trois requêtes partent simultanément
}

Pour les queries dépendantes où un fetch a besoin des données d’un autre, l’option enabled empêche l’exécution tant que la dépendance n’est pas prête.

function useUserOrders(userId: string | undefined) {
  return useQuery({
    queryKey: ['orders', userId],
    queryFn: () => fetchOrders(userId!),
    enabled: !!userId,
  });
}

Pas de waterfall, pas d’orchestration manuelle. La query attend que userId existe, puis fetche.

Queries infinies pour les données paginées

Le scroll infini et les patterns “charger plus” sont des citoyens de première classe. useInfiniteQuery gère des pages de données comme une seule entrée de cache, avec un support natif pour fetcher la page suivante.

import { useInfiniteQuery } from '@tanstack/react-query';

export function useFeed() {
  return useInfiniteQuery({
    queryKey: ['feed'],
    queryFn: ({ pageParam }) => fetchFeed(pageParam),
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  });
}
function Feed() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useFeed();

  return (
    <>
      {data?.pages.flatMap((page) =>
        page.items.map((item) => <FeedItem key={item.id} item={item} />)
      )}
      {hasNextPage ? (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          Charger plus
        </button>
      ) : null}
    </>
  );
}

Chaque page est mise en cache indépendamment, donc naviguer ailleurs puis revenir ne re-fetche pas toutes les pages depuis le début.

Conclusion

TanStack Query redéfinit la façon dont on pense aux données en React. Le state serveur n’est pas quelque chose à fourrer dans un store global. C’est un cache qui doit rester frais, gérer les accès concurrents gracieusement, et se dégrader proprement sur les réseaux lents. La librairie gère tout cela avec une API déclarative qui réduit le code de data-fetching d’un ordre de grandeur. Si votre app React communique avec une API, TanStack Query devrait être la première dépendance vers laquelle vous vous tournez.