React LogoArchitecture front-end
Architecture/Data Fetching

Tanstack Query

Historiquement, en React, on récupérait les données avec un useEffect.

Le problème du useEffect

Voici un code très classique pour récupérer une liste de produits :

import { useState, useEffect } from 'react';

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

export const ProductList = () => {
  const [data, setData] = useState<Product[] | null>(null);
  const [isLoading, setIsLoading] = useState<boolean>(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    let isMounted = true; // Pour éviter les fuites de mémoire (Race conditions)
    setIsLoading(true);

    fetch('/api/products')
      .then((res) => {
        if (!res.ok) throw new Error("Erreur réseau");
        return res.json();
      })
      .then((json: Product[]) => {
        if (isMounted) setData(json);
      })
      .catch((err) => {
        if (isMounted) setError(err instanceof Error ? err : new Error("Erreur inconnue"));
      })
      .finally(() => {
        if (isMounted) setIsLoading(false);
      });

    return () => {
      isMounted = false; // Cleanup function
    };
  }, []);

  if (isLoading) return <div>Chargement...</div>;
  if (error) return <div>Erreur : {error.message}</div>;
  if (!data) return null;

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

Ce code est lourd, verbeux, et pourtant, il manque l'essentiel : le cache. Si l'utilisateur change de page et revient, la requête repart de zéro. L'utilisateur revoit le spinner de chargement. C'est une très mauvaise expérience utilisateur (UX).

La solution : React Query

TanStack Query (anciennement React Query) est un gestionnaire d'état asynchrone. Il remplace totalement les useEffect de data fetching. Il gère le cache, les requêtes en arrière-plan, les tentatives en cas d'échec (retries), et bien plus.

L'installation et le Provider

Avant de pouvoir utiliser les hooks de React Query, il est nécessaire d'installer la librairie et d'englober notre application dans un QueryClientProvider. Ce provider est le cerveau absolu : c'est lui qui stocke le cache global de toutes vos requêtes.

npm install @tanstack/react-query
/* app/providers/QueryProvider.tsx */
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';

/* On instancie le client une seule fois (souvent en dehors du composant 
   ou alors conservé dans un state React) */
const queryClient = new QueryClient();

export const QueryProvider = ({ children }: { children: ReactNode }) => {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
};

Il suffit ensuite de wrapper la racine de votre application avec ce provider (typiquement dans votre main.tsx ou l'entrée de l'application). Si vous oubliez le Provider, une belle erreur rouge explosera dans votre console au premier hook appelé

Utilisation dans un composant

Voici exactement le même composant, réécrit avec React Query :

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

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

const fetchProducts = async (): Promise<Product[]> => {
  const res = await fetch('/api/products');
  if (!res.ok) throw new Error("Erreur réseau");
  return res.json();
};

export const ProductList = () => {
  /* useQuery gère la donnée, le chargement, les erreurs et le CACHE */
  const { data, isLoading, error } = useQuery<Product[], Error>({
    queryKey: ['products'], // La clé unique de cette requête dans le cache
    queryFn: fetchProducts, // La fonction qui va chercher la donnée
  });

  if (isLoading) return <div>Chargement...</div>;
  if (error) return <div>Erreur : {error.message}</div>;
  if (!data) return null;

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

Si l'utilisateur quitte la page et revient, React Query lui affichera instantanément la donnée en cache (0 spinner de chargement), tout en refaisant une requête discrète en arrière-plan pour vérifier si la donnée a changé.

L'API Publique avec FSD

Dans une architecture Feature Sliced Design (FSD) stricte, les composants UI ne devraient pas connaître les clés de cache (['products']). La bonne pratique est d'encapsuler useQuery dans un hook personnalisé situé dans le segment api ou model de votre module.

/* entities/product/api/useProducts.ts */
import { useQuery } from '@tanstack/react-query';
import { fetchProducts } from './productApi';
import { Product } from '../model/types';

export const useProducts = () => {
  return useQuery<Product[], Error>({
    queryKey: ['products'],
    queryFn: fetchProducts,
  });
};

Suspense et Error Boundaries

Gérer isLoading et isError manuellement dans chaque composant avec des if devient vite pénible quand l'application grossit. La tendance moderne en React est de déléguer cette responsabilité à l'architecture, en utilisant Suspense pour le chargement et Error Boundaries pour les erreurs.

React Query s'intègre parfaitement avec ce modèle :

import { useSuspenseQuery } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';

/* 1. On utilise useSuspenseQuery au lieu de useQuery.
   Ce hook ne retourne pas de isLoading, car React met en pause
   le rendu tant que la donnée n'est pas là. */
export const ProductList = () => {
  const { data: products } = useSuspenseQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
  });

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

/* 2. On encapsule le composant plus haut dans l'arbre */
export const ProductPage = () => {
  return (
    <ErrorBoundary fallback={<div className="error">Oups, l'API a planté !</div>}>
      <Suspense fallback={<div className="spinner">Chargement...</div>}>
        <ProductList />
      </Suspense>
    </ErrorBoundary>
  );
};

C'est extrêmement puissant : si l'API tombe, ou si vous jetez une erreur manuellement dans votre fetchProducts (par exemple si vous vous rendez compte que le format de données reçu n'est pas le bon), l'ErrorBoundary l'attrape au vol et affiche l'UI de secours. Votre composant ProductList, lui, reste pur et concentré sur l'affichage des données heureuses.

Exercice

J'ai mis en place une API pour cet exercice. Vous pouvez la trouver ici : https://frontend-architecture.teepan.fr/api/products.

On va remplacer les fausses données de notre e-commerce par les vrais produits provenant de l'API du cours. Mais attention : dans le monde réel, les APIs sont parfois "foireuses" (lenteurs, erreurs 500, ou problèmes de typage inattendus).

Dans cet exercice, vous devrez :

  • créer un fichier src/entities/product/api/productApi.ts et écrire une fonction asynchrone fetchProducts qui fait un appel GET vers l'API. Si !res.ok, levez une erreur explicite.
  • créer un hook useProducts qui utilise useSuspenseQuery (ou useQuery avec l'option suspense si vous êtes sur une ancienne version) au lieu du useQuery classique.
  • exporter ce hook dans l'API publique du module produit.
  • ouvrir le widget src/widgets/product-list/ui/ProductList.tsx et appeler votre hook. Le composant gagne en lisibilité puisqu'il n'a plus à gérer isLoading.
  • le plus important : Dans le parent qui instancie <ProductList />, wrappez le composant avec un <Suspense> natif de React et un <ErrorBoundary> (vous pouvez l'importer de react-error-boundary ou le coder). Testez de casser volontairement l'URL de l'API pour voir votre UI de fallback s'afficher.

L'architecture de ce projet suit le FSD que nous avons vu dans les chapitres précédents. Vous pouvez repartir de l'état actuel du projet et continuer à travailler sur stackblitz.