9 min de lectureReact
Centraliser les clés React Query dans une base de code Next.js qui grandit

Une fabrique de clés de requête typée qui corrige les invalidations manquées et les types perdus dans une application Next.js en croissance, avec les Server Components et une migration étape par étape.
Les clés de requête de React Query commencent par être inoffensives. On écrit ['jobs', id] inline, le cache fait son travail, et pendant quelques mois personne n'y pense. Puis la base de code grandit. De nouveaux écrans, de nouveaux contributeurs, puis une application mobile qui parle à la même API que le site. Un jour, une mutation réussit, le toast affiche « enregistré », et la liste de l'écran précédent montre toujours les anciennes données. Quelqu'un ajoute un appel à refetch() pour masquer le problème. Puis un autre. Six mois plus tard, la moitié des hooks relancent leur requête au montage parce que plus personne ne fait confiance au cache.
J'ai travaillé sur ce sujet dans deux bases de code. Sur HIÜRD, une marketplace de missions et de services locaux avec une application React Native et un site Next.js, j'ai centralisé les clés React Query. Sur Ever Teams, la plateforme open source de gestion d'équipe sur laquelle je travaille chez Ever Technologies, l'application web a fait passer ses hooks de données à React Query en juin 2025, à travers une série de pull requests publiques : j'ai ouvert la pull request de préparation et dix-sept des pull requests de migration, pendant qu'un autre contributeur migrait d'autres parties de l'application. Ce qui suit est l'approche que j'appliquerais aujourd'hui dès le premier jour, plus les parties qui ne comptent qu'une fois la base de code déjà grosse. Le code ci-dessous prend pour exemple un domaine de jobs et d'offres (bids) ; rien n'est tiré de l'une ou l'autre base de code.
Les symptômes de clés dispersées
Le premier symptôme est une invalidation manquée. Un écran lit un job avec ['jobs', id]. Un second écran, écrit par quelqu'un d'autre trois semaines plus tard, lit le même job avec ['job', id]. La mutation qui modifie le job invalide ['job']. L'un des deux écrans reste périmé, et le rapport de bug dit « parfois la modification n'apparaît pas », le genre de rapport qui mange une après-midi.
// Trois fichiers, trois orthographes de la même entrée de cache
useQuery({ queryKey: ['jobs', id], queryFn: () => fetchJob(id) });
useQuery({ queryKey: ['job', id], queryFn: () => fetchJob(id) });
useQuery({ queryKey: ['jobs', { id }], queryFn: () => fetchJob(id) });
// La mutation qui croit les rafraîchir toutes
queryClient.invalidateQueries({ queryKey: ['job'] });Le deuxième symptôme est la duplication. Les trois orthographes ci-dessus sont trois entrées de cache pour la même ressource serveur. Chacune déclenche sa propre requête, chacune a son propre staleTime, et une mise à jour optimiste écrite contre l'une d'elles laisse les deux autres intactes. Sur une application mobile, cela se voit sous forme de scintillement : l'écran de détail affiche la nouvelle valeur, vous revenez en arrière, la liste affiche l'ancienne pendant une seconde, puis relance la requête.
Le troisième symptôme est plus discret. Une clé inline est typée (string | number)[] au mieux. Rien ne relie la clé à la forme des données qu'elle met en cache, donc queryClient.getQueryData(['jobs', id]) renvoie unknown et chaque point d'appel fait un cast. Quand l'API change la forme de sa réponse, le compilateur ne peut pas vous dire quelles lectures du cache sont désormais fausses.
Une fabrique de clés de requête typée
Le remède est un petit objet par domaine qui possède toutes les clés de ce domaine. La forme que j'utilise est hiérarchique : une racine, un niveau pour les listes, un niveau pour les détails, et des feuilles qui prennent des paramètres.
// features/jobs/keys.ts
export type JobFilters = { status?: 'open' | 'closed'; page?: number };
export const jobKeys = {
all: ['jobs'] as const,
lists: () => [...jobKeys.all, 'list'] as const,
list: (filters: JobFilters) => [...jobKeys.lists(), filters] as const,
details: () => [...jobKeys.all, 'detail'] as const,
detail: (id: string) => [...jobKeys.details(), id] as const,
};C'est as const qui fait le vrai travail. Sans lui, jobKeys.detail(id) est typé string[]. Avec lui, le type devient readonly ['jobs', 'detail', string], un tuple dont les deux premières positions sont des types littéraux. Chaque hook et chaque mutation du domaine importe depuis ce fichier, donc une faute de frappe devient une erreur de compilation au lieu d'un écran périmé.
Colocaliser la clé et sa fonction de chargement
Depuis TanStack Query v5, queryOptions permet de regrouper la clé et la queryFn dans un seul objet typé. La clé porte le type des données, et c'est précisément ce qui corrige le troisième symptôme : getQueryData et setQueryData deviennent typés quand on leur passe une clé produite de cette manière.
// features/jobs/queries.ts
import { queryOptions } from '@tanstack/react-query';
import { jobKeys, type JobFilters } from './keys';
import { fetchJob, fetchJobs } from './api';
export const jobQueries = {
list: (filters: JobFilters) =>
queryOptions({
queryKey: jobKeys.list(filters),
queryFn: () => fetchJobs(filters),
}),
detail: (id: string) =>
queryOptions({
queryKey: jobKeys.detail(id),
queryFn: () => fetchJob(id),
}),
};
// Ailleurs : lecture typée, sans cast
const cached = queryClient.getQueryData(jobQueries.detail(id).queryKey);
// cached: Job | undefinedDeux règles gardent ce fichier utile dans une grande application. Il n'importe rien de React et ne porte aucune directive 'use client', donc les Server Components comme les hooks client peuvent l'importer. Et les objets de filtres sont normalisés avant d'atteindre la clé : React Query hache les clés avec un hachage stable, indépendant de l'ordre des propriétés, donc { status, page } et { page, status } correspondent, mais une propriété à undefined est retirée du hachage tandis qu'une propriété à null est conservée. Choisissez une convention et tenez-vous-y, sinon deux écrans finiront avec deux entrées pour ce que l'utilisateur voit comme la même liste.
Une invalidation ciblée et hiérarchique
La hiérarchie paie dans les mutations. invalidateQueries compare par préfixe, donc chaque niveau de la fabrique correspond à un périmètre différent. jobKeys.all invalide tout ce qui concerne les jobs. jobKeys.lists() invalide toutes les listes quels que soient les filtres et laisse les entrées de détail tranquilles. jobKeys.detail(id) ne touche qu'une entrée.
// features/bids/use-place-bid.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { jobKeys } from '../jobs/keys';
import { bidKeys } from './keys';
import { placeBid } from './api';
export function usePlaceBid() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: placeBid,
onSuccess: (bid) => {
queryClient.invalidateQueries({ queryKey: jobKeys.detail(bid.jobId) });
queryClient.invalidateQueries({ queryKey: bidKeys.lists() });
},
});
}La règle que je suis : une mutation invalide le préfixe le plus étroit dont on est sûr qu'il est périmé. Placer une offre change le nombre d'offres d'un job et la liste d'offres de l'utilisateur courant. Cela ne change pas toutes les listes de jobs, donc jobKeys.lists() reste hors du onSuccess. Quand une mutation touche vraiment tout un domaine, jobKeys.all le dit explicitement, ce qui vaut mieux que le queryClient.invalidateQueries() silencieux, sans argument, qui marque tout le cache comme périmé, relance toutes les requêtes actives et se traduit par une rafale de requêtes dans l'onglet Réseau.
Pour les mises à jour optimistes, les mêmes clés servent à setQueryData. Comme la clé vient de queryOptions, la fonction de mise à jour reçoit la valeur précédente typée, et un mauvais nom de champ échoue à la compilation.
Vivre avec les Server Components et l'hydratation
Dans l'App Router, un Server Component ne peut pas appeler useQuery. L'approche qui fonctionne consiste à précharger côté serveur, sérialiser le cache et laisser le hook client le récupérer. La fabrique devient le contrat entre les deux côtés : le serveur précharge jobQueries.list(filters), le client appelle useQuery(jobQueries.list(filters)), et comme les deux construisent la clé avec la même fonction, l'hydratation retrouve l'entrée. Quand les deux clés diffèrent d'un caractère, rien ne plante. Le client relance simplement la requête au montage et vous perdez le bénéfice sans vous en apercevoir.
// lib/query-client.ts
import { QueryClient, isServer } from '@tanstack/react-query';
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { staleTime: 60 * 1000 },
},
});
}
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
if (isServer) return makeQueryClient();
browserQueryClient ??= makeQueryClient();
return browserQueryClient;
}// app/jobs/page.tsx (Server Component)
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { jobQueries } from '@/features/jobs/queries';
import { JobList } from './job-list';
export default async function JobsPage() {
const queryClient = getQueryClient();
await queryClient.prefetchQuery(jobQueries.list({ status: 'open' }));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<JobList status="open" />
</HydrationBoundary>
);
}// app/jobs/job-list.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
import { jobQueries } from '@/features/jobs/queries';
export function JobList({ status }: { status: 'open' | 'closed' }) {
const { data } = useQuery(jobQueries.list({ status }));
return <ul>{data?.map((job) => <li key={job.id}>{job.title}</li>)}</ul>;
}Deux détails comptent ici. Un QueryClient neuf par requête serveur garde les données d'un utilisateur hors du HTML d'un autre ; la branche isServer ci-dessus s'en charge, et envelopper makeQueryClient dans le cache() de React permet à plusieurs Server Components de la même requête de le partager. Et un staleTime par défaut non nul empêche le client de relancer la requête dès son montage, ce qui rendrait le préchargement inutile.
Migrer une grande base de code étape par étape
Réécrire chaque hook dans une seule branche, c'est ainsi qu'une migration meurt en revue. La séquence ci-dessous garde l'application livrable à chaque commit.
Commencez par un inventaire. Un grep sur queryKey: [ donne la liste des clés inline ; groupez-les par premier élément et vous avez vos domaines, plus les variantes d'orthographe qui créaient silencieusement des doublons. Écrivez ensuite la fabrique d'un domaine à côté des hooks existants, en veillant à ce que les tableaux qu'elle produit soient identiques aux tableaux inline déjà en usage, là où c'est possible. Ainsi le cache ne se scinde pas pendant la transition : les anciens points d'appel et les points d'appel migrés touchent les mêmes entrées.
Remplacez d'abord les lectures. Chaque useQuery du domaine passe à jobQueries.x(...), une pull request par domaine, assez petite pour être relue en vingt minutes. Viennent ensuite les écritures : les appels à invalidateQueries et setQueryData basculent sur la fabrique, et c'est à cette étape qu'on découvre les invalidations qui ne touchaient rien. Renommez ou remodelez les clés d'un domaine seulement une fois qu'il est entièrement migré, parce qu'à ce moment-là le compilateur indique chaque endroit qui doit suivre.
La dernière étape est un garde-fou pour que les clés inline ne reviennent pas. Une restriction de lint sur les tableaux littéraux dans une propriété queryKey hors des fichiers keys.ts suffit ; un codemod est optionnel et vaut rarement le temps investi comparé aux pull requests par domaine.
Ce que la migration d'Ever Teams a montré
Ever Teams est un monorepo Next.js et NestJS avec des dizaines de contributeurs, donc chaque étape est visible dans l'historique des pull requests. À la relecture, l'ordre est la partie intéressante. En septembre 2024, l'application web a fait passer son état client de Recoil à Jotai (PR #3037). Fin avril 2025, une série de pull requests a réorganisé apps/web : les composants dans core/components (#3771), les hooks dans core/hooks (#3777), les types dans core/types (#3778), et un nettoyage du répertoire app (#3780). En mai, les stores Jotai ont été découpés par domaine (#3855) et une pull request de préparation a été fusionnée (#3864, « Prepare/react query feature »). En juin 2025, les hooks sont passés à React Query par petites pull requests entre #3872 et #3915, dont dix-sept ouvertes par moi, à côté d'une série parallèle menée par un autre contributeur. Le hook d'authentification, useAuthenticateUser, a eu sa propre pull request (#3902).
Lu dans cet ordre, l'historique montre la même idée que le plan de migration ci-dessus : préparer le terrain, puis déplacer une tranche à la fois. Une fois l'état client séparé de l'état serveur, React Query n'a plus que la moitié serveur à prendre. Une fois les hooks réunis à un seul endroit, ils peuvent être migrés par petites pull requests. Et une pull request de préparation fusionnée avant la série permet à chaque pull request suivante de rester petite, au lieu d'une seule branche que personne ne peut relire.
Ce qui est vérifié dans cette section
Les numéros et dates de pull requests viennent de l'historique public de github.com/ever-co/ever-teams, consulté le 2026-10-05. Aucun nombre de requêtes ni aucun temps avant et après la migration n'est cité ici, parce qu'aucun n'a été vérifié pour cet article. Tout chiffre ajouté plus tard portera sa date de mesure.
Ce que je mettrais en place dès le départ
Sur une nouvelle base de code, j'adopterais queryOptions dès le premier hook, parce que le getQueryData typé suffit à lui seul à retirer la plupart des casts qui s'accumulent dans une base de code. J'ajouterais la restriction de lint dès le premier jour, avant qu'il y ait quoi que ce soit à migrer. Et quand la forme de la réponse de l'API change, je mettrais un segment de version dans la clé racine, ['jobs', 'v2'], pour qu'un cache persisté issu d'un build plus ancien ne puisse pas alimenter la nouvelle interface avec de mauvaises données.
Désormais, je surveille le périmètre des invalidations. Avec une fabrique, écrire jobKeys.all partout est facile, et quelques mois de cette habitude font de chaque mutation un rechargement de tout le domaine. L'onglet Réseau après une seule mutation est le test : si plus que la liste et l'entrée de détail concernées se rechargent, il manque un niveau de clé ou une mutation invalide trop haut.