Construire une couche cohérente de chargement des données dans React avec TanStack Query

Illustration de Construire une couche cohérente de chargement des données dans React avec TanStack Query

Un pattern de chargement des données dans React fondé sur TanStack Query, des hooks personnalisés et un petit DSL pour API REST.

Dans React, le chargement des données peut aller d’un simple appel à fetch dans un composant jusqu’à l’utilisation de frameworks RPC élaborés. Dans une petite application, les appels à useQuery peuvent être répartis entre les composants. À mesure que l’application grandit, vous pouvez les extraire dans des hooks personnalisés qui encapsulent useQuery ou useMutation, les clés de requête et les options. Si votre backend expose un routeur fortement typé, comme tRPC, vous pouvez même générer automatiquement des hooks entièrement typés et des fabriques de clés de requête. L’intégration de tRPC avec TanStack Query fournit des fabriques pour queryKeys, queryOptions et mutationOptions, et encourage l’appel à useQuery(trpc.procedure.queryOptions(...)).

Cependant, la plupart des API REST ne fournissent pas de routeur prêt à l’emploi. Même avec tRPC, vous pouvez souhaiter mieux contrôler les aspects réseau, notamment les en-têtes d’authentification, les délais d’attente et les mises à jour optimistes. Cet article décrit un pattern inspiré de l’approche @trpc/tanstack‑react‑query, mais adapté à une API REST générique. L’idée centrale consiste à encapsuler Axios et TanStack Query dans un petit langage propre au domaine (DSL) qui centralise les endpoints, la génération des clés de requête et les options réseau. On obtient ainsi une expérience de développement proche de tRPC sans perdre en flexibilité : vous définissez vos propres types et adaptez le comportement de React Query pour chaque endpoint.

Avant d’examiner le pattern, précisons son contexte. Cette approche vient de la construction d’une application d’entreprise utilisée par un service informatique pour gérer les processus de maintenance du parc et les tickets d’assistance. Le système doit garantir un chargement de données prévisible, typé et maintenable sur des dizaines d’écrans : listes, tableaux de bord, pages de détail, assistants et parcours fondés sur des fenêtres modales. Axios est le client HTTP de ce projet, mais l’architecture n’en dépend pas. Toute couche de chargement, qu’il s’agisse de fetch, Ky, SuperAgent ou d’un wrapper personnalisé, peut être placée derrière les mêmes fabriques de requêtes et de mutations. Le but n’est pas de promouvoir une bibliothèque précise, mais de montrer comment un petit DSL inspiré de tRPC peut normaliser les endpoints, stabiliser les clés de requête et offrir une expérience de développement cohérente dans une base de code React en forte croissance.

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 atténuent certains de ces problèmes en encapsulant un appel à useQuery et en renvoyant son résultat. Ils conviennent aux requêtes simples, mais obligent encore à composer manuellement les options et la logique d’invalidation. Un pattern plus évolutif consiste à générer des fabriques de requêtes et de mutations. C’est ce que fait l’intégration tRPC : elle expose des fabriques comme queryOptions, mutationOptions et queryKey, dont les objets peuvent être transmis à useQuery/useMutation. Ces fabriques connaissent le chemin de la procédure, les types d’entrée et les options par défaut, et produisent des clés cohérentes.

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. En interne, la fabrique renvoie alors le skipToken de React Query, qui désactive la requête de façon sûre pour les types. La documentation précise que skipToken désactive la requête et constitue une bonne alternative à enabled: false.

4. Le provider du client de requêtes

Encapsulez votre application dans un unique QueryClientProvider afin que TanStack Query puisse mettre les données en cache dans toute l’arborescence des composants. Il est important de ne créer le QueryClient qu’une seule fois : le recréer à 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);

Grâce à la fabrique, vous n’avez pas à mémoriser le verbe HTTP ni l’endpoint : la fonction de mutation est déjà configurée. Vous pouvez même transmettre d’autres options de React Query, comme onMutate et onError, au moyen de l’argument de surcharge.

6. Assembler les différents éléments

Pour utiliser ce pattern dans un nouveau projet :

  1. Installez les dépendances : vous avez besoin de @tanstack/react-query, d’axios et, éventuellement, de 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.

Conclusion

En encapsulant Axios et TanStack Query dans un petit DSL, vous pouvez reproduire l’ergonomie de l’intégration React Query de tRPC tout en gardant un contrôle complet sur le comportement réseau et le typage. Le pattern centralise les endpoints, génère des clés de requête stables et masque le code standard nécessaire à la construction des objets queryFn. Vous pouvez adapter la logique de staleTime, enabled, placeholderData et mutation à chaque endpoint, puis étendre les fabriques pour gérer les téléversements, les téléchargements ou des en-têtes personnalisés. Après cette configuration initiale, les composants restent légers et centrés sur la logique de l’interface, tandis que la couche de données demeure cohérente et facile à maintenir.

Bon code !

Articles liés