Structurer le chargement des données React avec TanStack Query

Illustration de Structurer le chargement des données React avec TanStack Query

Centraliser les endpoints, les clés de cache et les options réseau d’une API REST avec TanStack Query.

Dans une petite application React, quelques appels à useQuery dans les composants suffisent. Quand les écrans se multiplient, les clés de cache, les options réseau et les règles d’invalidation se dispersent. Des hooks personnalisés réduisent la répétition, mais ils peuvent eux aussi diverger. tRPC évite ce problème avec des fabriques typées telles que queryKeys, queryOptions et mutationOptions.

Une API REST n’expose pas ce routeur. J’ai donc repris la même idée dans un petit DSL qui réunit Axios et TanStack Query. Il centralise les endpoints, les clés de requête, l’authentification et les options réseau tout en laissant chaque endpoint définir ses types.

J’ai extrait ce modèle d’une application interne qui gère la maintenance d’un parc informatique et les tickets d’assistance. Des dizaines d’écrans chargent les mêmes ressources sous des formes différentes. Le projet utilise Axios, mais les fabriques présentées ici peuvent appeler fetch, Ky ou un autre client. Elles normalisent surtout les endpoints et les clés de cache.

Pourquoi ne pas appeler simplement useQuery partout ?

Les débutants placent souvent des appels à useQuery et useMutation directement dans les composants. Cette approche convient aux applications très simples, mais devient vite répétitive :

  • Des clés de requête dispersées. Les clés de requête déterminent la manière dont TanStack Query met les réponses en cache. Chaque clé doit identifier les données de façon unique. La création manuelle de ces clés dans des dizaines de composants favorise les erreurs.

  • Aucune valeur par défaut commune. TanStack Query propose de nombreuses options, comme staleTime et placeholderData, à adapter pour chaque endpoint. Sans emplacement central où définir leurs valeurs par défaut, le comportement devient incohérent.

  • Les préoccupations réseau se retrouvent dans le code de l’interface. Dans les applications plus importantes, il faut injecter des jetons d’authentification, traiter les codes d’erreur, afficher des notifications, téléverser des fichiers avec un suivi de progression et invalider plusieurs requêtes. Répéter ces opérations dans chaque composant produit rapidement beaucoup de code standard.

Les hooks personnalisés regroupent un appel à useQuery, mais chacun doit encore composer ses options et ses invalidations. Les fabriques de requêtes et de mutations déplacent cette composition dans la définition de l’endpoint. L’intégration tRPC suit cette approche avec queryOptions, mutationOptions et queryKey.

Mon objectif est de reproduire cette expérience pour une API REST.

Vue d’ensemble du pattern

Le pattern repose sur six éléments :

1. Un client HTTP avec authentification et garde-fous

Au lieu d’appeler directement fetch, créez une instance Axios qui injecte un en-tête Authorization depuis votre store d’authentification et gère l’expiration du jeton. Utilisez des intercepteurs pour définir un délai d’attente long et déconnecter l’utilisateur après une réponse 401 ou l’expiration du JWT. Le client expose une API simple client.get/post/put/delete qui renvoie les données de la réponse.

TypeScript
import axios, { AxiosError } from "axios";
import { useAuthStore } from "@/stores";

export const client = axios.create({
  baseURL: "/api",
  headers: { "Content-Type": "application/json", Accept: "application/json" },
});

client.interceptors.request.use((config) => {
  config.timeout = 900_000;
  const token = useAuthStore.getState().access_token;
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

client.interceptors.response.use(
  (res) => res,
  (err) => {
    // logout on 401 or expired JWT
    if (
      err instanceof AxiosError &&
      (err.response?.status === 401 ||
        err.response?.data?.error === "jwt expired")
    ) {
      useAuthStore.getState().logout();
    }
    throw err;
  }
);

Isoler la logique d’authentification dans le client évite de mêler les préoccupations réseau aux composants. Vous pourrez ensuite y ajouter des nouvelles tentatives, l’annulation des requêtes ou de l’instrumentation.

2. Des définitions d’endpoints centralisées

Placez ensuite tous les chemins de l’API dans un seul fichier endpoint.ts. Chaque entrée est une chaîne ou une fonction pure qui construit un chemin. Puisque les composants ne concatènent jamais eux-mêmes les chaînes, l’emplacement où modifier les routes est explicite, et TypeScript peut vous aider à éviter les paramètres incompatibles.

TypeScript
import qs from "qs";

export const endpoint = {
  getTickets: "/tickets",
  getTicket: (id: number) => `/tickets/${id}`,
  getTicketReport: (dimension: "priority" | "subject", range?: DateRange) => {
    const query = qs.stringify(
      { dimension, range },
      { skipNulls: true, addQueryPrefix: true }
    );
    return `/tickets/reports${query}`;
  },
  // ...more endpoints
};

La centralisation des endpoints réduit les fautes de frappe et facilite l’examen des routes disponibles. Puisque les fonctions renvoient de simples chaînes, elles peuvent servir au client comme à d’autres outils, notamment pour créer des mocks.

3. Des fabriques de requêtes et de mutations

C’est le cœur du pattern. Au lieu d’écrire des hooks personnalisés, j’expose de petites fonctions de fabrication qui produisent les options de TanStack Query. Inspirées de l’intégration tRPC, mes fabriques exposent trois éléments :

  • endpoint : le chemin ou la fonction qui le génère ;

  • queryKey(params) : une fonction de clé stable qui identifie de façon unique les données mises en cache ;

  • queryOptions(params, overrides?) : construit un objet d’options destiné à useQuery, avec queryKey, queryFn, la valeur par défaut de staleTime et les indicateurs facultatifs.

Pour les mutations, une fonction analogue, mutationOptions(params, overrides?), sélectionne le verbe HTTP et renvoie une mutationFn. Des fonctions de téléversement et de téléchargement peuvent également suivre la progression et renvoyer un Blob lorsque cela convient.

TypeScript
const DEFAULT_STALE_TIME = 2 * 60 * 5000;

function resolvePath<TParams>(path: Endpoint<TParams>, params: TParams) {
  return typeof path === "function" ? path(params) : path;
}

function createQuery<TParams, TResult>({
  path,
  key,
  enabled,
  staleTime,
  placeholderData,
}: QueryBuilder<TParams, TResult>) {
  return {
    endpoint: path,
    queryKey: (params: TParams) => key(params),
    queryOptions: (params: TParams, overrides = {}) => {
      const resolved = resolvePath(path, params);
      // query function uses Axios client and abort signal
      const queryFn: QueryFunction<TResult> = async ({ signal }) =>
        (await client.get<TResult>(resolved, { signal })).data;

      return {
        queryKey: key(params),
        // skip the query if enabled() returns false.
        // React Query’s skipToken is a type‑safe way to disable a query.
        queryFn: enabled ? (enabled(params) ? queryFn : skipToken) : queryFn,
        staleTime: staleTime ?? DEFAULT_STALE_TIME,
        placeholderData,
        ...overrides,
      } satisfies UseQueryOptions<TResult>;
    },
  };
}

function createMutation<TParams, TVariables = void, TResult = void>({
  path,
  method,
}: MutationBuilder<TParams>) {
  return {
    endpoint: path,
    mutationOptions: (params: TParams, overrides = {}) => {
      const resolved = resolvePath(path, params);
      const mutationFn: MutationFunction<TResult, TVariables> = async (
        variables
      ) => {
        switch (method) {
          case "put":
            return (await client.put<TResult>(resolved, variables)).data;
          case "patch":
            return (await client.patch<TResult>(resolved, variables)).data;
          case "delete":
            return (await client.delete<TResult>(resolved)).data;
          default:
            return (await client.post<TResult>(resolved, variables)).data;
        }
      };

      return {
        mutationFn,
        ...overrides,
      } satisfies UseMutationOptions<TResult, unknown, TVariables>;
    },
  };
}

Pour assembler une API propre au domaine, appelez ces fonctions pour chaque ressource et chaque opération. Par exemple, voici comment créer les requêtes et les mutations relatives aux tickets :

TypeScript
import { endpoint } from "./endpoint";
import { createQuery, createMutation } from "./request";

export const tickets = {
  list: createQuery<TicketFilters, Paginated<Ticket>>({
    path: endpoint.getTickets,
    key: (filters: TicketFilters) => ["tickets", filters],
    // disable query when filters are empty
    enabled: (f) => Boolean(f),
    // keep previous page visible while loading the next page
    placeholderData: keepPreviousData,
  }),
  detail: createQuery<number, Ticket>({
    path: endpoint.getTicket,
    key: (id: number) => ["ticket", id],
  }),
  create: createMutation<void, AddTicketPayload, Ticket>({
    path: endpoint.getTickets,
    method: "post",
  }),
  update: createMutation<number, UpdateTicketPayload, Ticket>({
    path: endpoint.getTicket,
    method: "patch",
  }),
  delete: createMutation<number>({
    path: endpoint.getTicket,
    method: "delete",
  }),
};

La requête de liste utilise placeholderData: keepPreviousData. Lorsque vous changez de page ou de filtre, la liste précédente reste donc visible pendant le chargement des nouvelles données. Dans React Query v5, l’association de placeholderData à la valeur spéciale keepPreviousData conserve les données précédentes dans le cache et expose un indicateur isPlaceholderData. Celui-ci permet d’afficher un indicateur de chargement discret tout en conservant l’ancienne liste.

Pour les paramètres facultatifs, la fonction de rappel enabled renvoie false lorsqu’un paramètre manque. La fabrique renvoie alors le skipToken de React Query, qui désactive la requête sans perdre le typage. La documentation le propose comme solution de remplacement à enabled: false.

4. Le provider du client de requêtes

Encapsulez l’application dans un seul QueryClientProvider pour partager le cache dans toute l’arborescence. Créez aussi le QueryClient une seule fois. Une nouvelle instance à chaque rendu réinitialiserait le cache.

Voici comment je configure mes providers :

TypeScript
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: { retry: false }, // optional, adapt
  },
});

export function TanStackQueryProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

Dans main.tsx, placez ce provider autour du routeur et des autres providers globaux.

5. Utilisation dans les composants

L’utilisation du DSL dans les composants est directe :

Pages de détail

TypeScript
const { id } = useParams();

// the type of data will be correctly infered to: Ticket | undefined
const { data, isPending } = useQuery(api.tickets.detail.queryOptions(id));

if (!data && isPending) return <Skeleton />;
return <TicketDetails ticket={data} />;

Listes avec filtrage et pagination

TypeScript
const filters = useMemo(
  () => ({
    search,
    page,
    pageSize,
  }),
  [search, page, pageSize]
);

const { data, isPending, isPlaceholderData } = useQuery(
  api.tickets.list.queryOptions(filters)
);

return (
  <>
    {isPending && !isPlaceholderData ? <Spinner /> : null}
    <TicketTable tickets={data?.items ?? []} />
    <Pagination
      page={page}
      total={data?.meta.totalPages ?? 1}
      onChange={(p) => setPage(p)}
    />
  </>
);

Puisque placeholderData: keepPreviousData est configuré dans l’API, changer les filtres ne vide pas le tableau. L’ancienne page reste affichée pendant le chargement de la nouvelle. Vous pouvez utiliser isPlaceholderData pour atténuer le tableau ou afficher une barre de chargement discrète.

Mutations

Pour les opérations de création, de mise à jour et de suppression, appelez useMutation avec les options produites par la fabrique. Vous pouvez aussi invalider les requêtes après une opération réussie :

TypeScript
const queryClient = useQueryClient();
const mutation = useMutation(
  api.tickets.create.mutationOptions(undefined, {
    onSuccess: async () => {
      // invalidate the list and any related dashboard queries
      await Promise.all([
        queryClient.invalidateQueries({
          queryKey: api.tickets.list.queryKey(),
        }),
        queryClient.invalidateQueries({
          queryKey: api.dashboard.overview.queryKey(),
        }),
      ]);
      toast.success("Ticket created");
    },
  })
);

// trigger mutation
mutation.mutate(formData);

La fabrique connaît déjà le verbe HTTP et l’endpoint. L’argument de surcharge accepte encore des options React Query comme onMutate et onError.

6. Assembler les différents éléments

Pour utiliser ce pattern dans un nouveau projet :

  1. Installez @tanstack/react-query, axios et, si nécessaire, qs pour construire les chaînes de requête.

  2. Encapsulez l’application dans un QueryClientProvider. Créez le client une seule fois et transmettez-le au provider.

  3. Créez un client Axios avec une URL de base et des intercepteurs pour l’authentification et le traitement des erreurs.

  4. Centralisez les endpoints dans un fichier de fonctions pures qui construisent des chaînes.

  5. Écrivez les fabriques de requêtes et de mutations. Fournissez des valeurs par défaut comme staleTime et placeholderData, puis renvoyez skipToken lorsque les requêtes doivent être désactivées.

  6. Regroupez l’API par domaine, par exemple api.tickets.list ou api.users.detail, et exportez les fabriques de chaque opération.

  7. Utilisez useQuery et useMutation dans vos composants en leur transmettant les options produites par les fabriques de l’API. Invalidez les requêtes avec queryClient.invalidateQueries et les clés de requête fournies par l’API.

Avantages et compromis

Avantages

  • Centraliser la queryKey et les options empêche de mettre accidentellement en cache des données différentes sous une même clé. Cette approche garantit également l’application cohérente des valeurs par défaut comme staleTime ou retry.

  • Puisque les fabriques renvoient de simples options TanStack Query, vous pouvez les transmettre à n’importe quel hook de requête (useQuery, useSuspenseQuery, useInfiniteQuery) ou à des utilitaires comme queryClient.prefetchQuery. Le pattern ne masque pas React Query ; il réduit simplement le code standard.

  • L’intégration de tRPC recommande d’utiliser des fabriques pour queryOptions, mutationOptions et queryKey. Mon pattern reprend cette API : vous écrivez useQuery(api.tickets.detail.queryOptions(id)) au lieu de useQuery(['tickets', id], () => client.get(...)). La différence tient au fait que vous définissez vos propres types au lieu de laisser tRPC les inférer. Ce typage manuel demande davantage de travail au départ, mais offre un contrôle complet sur la forme des réponses et permet d’utiliser n’importe quel backend, pas seulement tRPC.

Compromis

  • Le principal inconvénient par rapport à tRPC est que vos fabriques ne peuvent pas inférer les types d’entrée et de sortie depuis un routeur. Vous devez définir TParams et TResult pour chaque requête et chaque mutation. Le pattern centralise néanmoins ces définitions, ce qui simplifie leur mise à jour.

  • Bien que le pattern centralise les endpoints et les options, vous devez toujours respecter les bonnes pratiques : utiliser des clés de requête stables, invalider correctement les requêtes et éviter de créer plusieurs instances de QueryClient. La documentation de React Query souligne que les requêtes sont désactivées par défaut avec skipToken, mais l’appel à refetch() sur une requête utilisant skipToken ne fonctionne pas. Il faut tenir compte de ces cas particuliers.

Ce que cette couche garantit

Le DSL centralise les endpoints, les clés de cache et la construction de queryFn. Chaque opération garde ses propres réglages pour staleTime, enabled ou placeholderData. Les composants reçoivent des options prêtes à transmettre à TanStack Query.

Cette couche ne supprime pas le travail de conception. Il faut encore choisir des clés stables, invalider les bonnes ressources et traiter les erreurs. Elle donne un seul endroit pour prendre ces décisions et les réutiliser dans tous les écrans.

Ce que cette couche apporte

Cette couche réduit les décisions répétées dans les composants. Elle ne remplace pas un contrat d’API généré ni la validation des réponses au moment de l’exécution. Son intérêt tient surtout à des clés stables, des options regroupées et un point clair pour gérer l’authentification et les erreurs réseau.

Articles liés