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
staleTimeetplaceholderData, à 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.
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.
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, avecqueryKey,queryFn, la valeur par défaut destaleTimeet 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.
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 :
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 :
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
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
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 :
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 :
-
Installez
@tanstack/react-query,axioset, si nécessaire,qspour construire les chaînes de requête. -
Encapsulez l’application dans un
QueryClientProvider. Créez le client une seule fois et transmettez-le au provider. -
Créez un client Axios avec une URL de base et des intercepteurs pour l’authentification et le traitement des erreurs.
-
Centralisez les endpoints dans un fichier de fonctions pures qui construisent des chaînes.
-
Écrivez les fabriques de requêtes et de mutations. Fournissez des valeurs par défaut comme
staleTimeetplaceholderData, puis renvoyezskipTokenlorsque les requêtes doivent être désactivées. -
Regroupez l’API par domaine, par exemple
api.tickets.listouapi.users.detail, et exportez les fabriques de chaque opération. -
Utilisez
useQueryetuseMutationdans vos composants en leur transmettant les options produites par les fabriques de l’API. Invalidez les requêtes avecqueryClient.invalidateQuerieset les clés de requête fournies par l’API.
Avantages et compromis
Avantages
-
Centraliser la
queryKeyet 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 commestaleTimeouretry. -
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 commequeryClient.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,mutationOptionsetqueryKey. Mon pattern reprend cette API : vous écrivezuseQuery(api.tickets.detail.queryOptions(id))au lieu deuseQuery(['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
TParamsetTResultpour 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 avecskipToken, mais l’appel àrefetch()sur une requête utilisantskipTokenne 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.