Structurer une couche de données TanStack Query, des options de requête aux annulations
Construisez une couche de données TanStack Query étape par étape : options de requête partagées, générateurs de clés, sélecteurs, pagination, préchargement, invalidation centrale et mises à jour optimistes sécurisées.
Les bases de code TanStack Query (anciennement React Query) ont tendance à accumuler des clés redondantes, des invalidations oubliées et des indicateurs de chargement un peu partout, non pas à cause de la bibliothèque elle-même, mais en raison d’un manque de structure. Ce guide construit une fonctionnalité, une page des contacts, à partir d’une seule requête pour en arriver à une petite couche de données dotée d’options partagées, de générateurs de clés, d’invalidations automatiques et de suppressions optimistes, tout en soulignant les pièges que chaque approche cache. Si vous hésitez encore quant à l’endroit où placer l’état du serveur, notre comparaison de React Query et Redux pour l’état du serveur aborde d’abord cette question.
La requête la plus simple et utile
Une requête nécessite une clé et une fonction. Ce composant récupère les contacts et gère les états en attente ainsi que les erreurs.
import { useQuery } from '@tanstack/react-query';
import { getContacts } from './api/client';
function ContactsTable() {
const { data, isPending, isError, refetch } = useQuery({
queryKey: ['contacts'],
queryFn: getContacts,
});
if (isPending) return <LoadingSpinner />;
if (isError) return <ErrorAlert onRetry={refetch} />;
return <Table data={data} />;
}
queryKey n’est pas une étiquette. C’est l’identifiant de l’entrée du cache : chaque composant qui demande ['contacts'] partage les mêmes données, la même déduplication des requêtes ainsi que le même rechargement en arrière-plan. Presque tous les modèles présentés ci-dessous visent en réalité à gérer correctement les clés.
Partage des définitions de requêtes
Encadrer les requêtes répétées dans un hook personnalisé
Lorsque deux composants ont besoin des mêmes données, un hook personnalisé conserve une seule définition et laisse les composants se concentrer uniquement sur l’affichage.
// queries/contacts.ts
export function useContacts() {
return useQuery({
queryKey: ['contacts'],
queryFn: getContacts,
});
}
// Component
function ContactsTable() {
const { data, isPending, isError } = useContacts();
// Clean, focused component logic
}
Préférer les objets queryOptions aux hooks
Un hook ne peut être appelé qu’à l’intérieur d’un composant. Un objet options simple créé avec queryOptions peut être utilisé par des hooks, par prefetchQuery, par getQueryData ainsi que par les chargeurs de route.
import { queryOptions } from '@tanstack/react-query';
export const contactsQueryOptions = queryOptions({
queryKey: ['contacts'],
queryFn: getContacts,
});
Cela présente deux avantages. Premièrement, l’inférence de type : les balises queryOptions attribuent au clé le type de données de la requête, ce qui permet à queryClient.getQueryData(contactsQueryOptions.queryKey) de retourner une valeur déjà typée sans avoir besoin de génériques manuels. Deuxièmement, la composabilité : vous pouvez étendre l’objet et modifier ou ajouter des champs selon le point d’appel, tout comme le fait le deuxième composant avec select.
// Use directly
function ContactsList() {
const { data } = useQuery(contactsQueryOptions);
return <List items={data} />;
}
// Extend with custom selectors
function ContactsCount() {
const { data } = useQuery({
...contactsQueryOptions,
select: (contacts) => contacts.length,
});
return <Badge count={data} />;
}
Lecture efficace des données
Sélecteurs qui limitent les réaffichages
select transforme les données mémorisées avant qu’elles n’atteignent le composant, ce qui constitue également une optimisation de rendu. Il peut également être intégré dans les options partagées :
const contactsQueryOptions = queryOptions({
queryKey: ['contacts'],
queryFn: getContacts,
select: (data) => data.length,
});
Le composant se rérendu à nouveau en fonction du résultat sélectionné. S’il ne sélectionne que le comptage et que le serveur modifie le nom d’un contact, le comptage reste inchangé et le composant ne se met pas à jour. Sur les écrans où de nombreux composants affichent la même liste longue, cela évite de nombreuses rendus inutiles. Une précaution : une flèche select en ligne crée une nouvelle fonction à chaque rendu, ce qui la fait s’exécuter à nouveau à chaque fois ; pour des transformations coûteuses, définissez-la en dehors du composant ou mémorisez-la.
Consultes paramétrées : chaque entrée doit figurer dans la clé
Une page de détail nécessite une requête qui dépend d’un ID. Une fonction factory qui retourne les options maintient cet ordre logique.
export const contactQueryOptions = (contactId: string) =>
queryOptions({
queryKey: ['contacts', contactId],
queryFn: () => getContact(contactId),
});
// Usage
function ContactPage() {
const { id } = useParams();
const { data } = useQuery(contactQueryOptions(id));
return <ContactDetails contact={data} />;
}
La règle qui empêche l’un des bugs les plus courants en production : toute variable utilisée par la fonction de requête doit figurer dans la clé. Si l’on omet contactId, le cache traite tous les contacts comme une seule entrée, ce qui peut faire que l’utilisateur voie temporairement les informations d’une autre personne. Avec un routeur, il faut également tenir compte du fait que id peut être undefined ; l’option enabled permet de conserver la requête jusqu’à ce que cette valeur soit disponible.
La pagination est une requête paramétrée associée à un état
La pagination ne nécessite rien de spécial : le numéro de page est inclus dans la clé, et sa modification dans l’état génère une nouvelle requête.
export const paginatedContactsOptions = (page: number, pageSize: number) =>
queryOptions({
queryKey: ['contacts', 'paginated', page, pageSize],
queryFn: () => getContacts({ page, pageSize }),
});
function ContactsTable() {
const [page, setPage] = useState(1);
const { data } = useQuery(paginatedContactsOptions(page, 10));
return (
<>
<Table data={data.items} />
<Pagination
currentPage={page}
onNext={() => setPage(p => p + 1)}
/>
</>
);
}
Aucun effet particulier n’est nécessaire pour recharger les données ; une nouvelle clé signifie simplement une nouvelle requête. Deux améliorations sont importantes en pratique. Lors de la première affichage, data est undefined, donc data.items nécessite une protection. De plus, à chaque changement de page, la nouvelle clé commence vide, ce qui provoque un affichage instantané du tableau ; en définissant placeholderData: keepPreviousData, on conserve la page précédente visible pendant que la suivante se charge.
Précharger la page que les utilisateurs demanderont ensuite
En combinant cela avec le préchargement, la page suivante est généralement prête avant même que l’utilisateur n’appuie sur le bouton.
function ContactsTable() {
const [page, setPage] = useState(1);
const queryClient = useQueryClient();
const { data } = useQuery(paginatedContactsOptions(page, 10));
useEffect(() => {
// Silently load the next page in the background
queryClient.prefetchQuery(
paginatedContactsOptions(page + 1, 10)
);
}, [page, queryClient]);
return <Table data={data} />;
}
prefetchQuery remplit le cache sans inscrire de composant. Lorsque l’utilisateur passe à la page suivante, la requête trouve des données fraîches et les affiche immédiatement. Cela fonctionne également en survol ou avant que la route ne soit chargée, mais il faut rester ciblé : chaque préchargement représente une vraie requête.
Listes infinies avec curseurs
Pour les fonctionnalités « charger plus » ou défilement infini, useInfiniteQuery stocke une liste de pages et gère automatiquement le curseur. Vous décrivez comment trouver le prochain curseur dans getNextPageParam, et la bibliothèque s’occupe du suivi.
export const infiniteContactsOptions = queryOptions({
queryKey: ['contacts', 'infinite'],
queryFn: ({ pageParam }) => getContacts({ cursor: pageParam }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
function InfiniteContactsList() {
const {
data,
fetchNextPage,
isFetchingNextPage
} = useInfiniteQuery(infiniteContactsOptions);
return (
<>
{data.pages.map(page =>
page.items.map(contact => (
<ContactCard key={contact.id} {...contact} />
))
)}
<button onClick={() => fetchNextPage()}>
{isFetchingNextPage ? 'Loading...' : 'Load More'}
</button>
</>
);
}
Notez que dans TanStack Query v5, l’aide dédiée à ce cas est infiniteQueryOptions, qui attribue les types corrects aux champs infinis ; consultez la documentation actuelle pour savoir si queryOptions les rejette. Utilisez également hasNextPage pour cacher le bouton dès que getNextPageParam renvoie undefined.
Assurer la cohérence des clés avec une usine
Les clés manuellement écrites varient : un fichier écrit ['contacts', 'list'], un autre ['contact', 'lists'], et la validation manque silencieusement ces différences. Une usine de clés décrit la hiérarchie une seule fois.
export const contactKeys = {
all: ['contacts'] as const,
lists: () => [...contactKeys.all, 'list'] as const,
list: (filters: ContactFilters) =>
[...contactKeys.lists(), filters] as const,
details: () => [...contactKeys.all, 'detail'] as const,
detail: (id: string) =>
[...contactKeys.details(), id] as const,
};
// Usage in queries
export const contactQueryOptions = (id: string) =>
queryOptions({
queryKey: contactKeys.detail(id),
queryFn: () => getContact(id),
});
// Surgical cache invalidation
queryClient.invalidateQueries({
queryKey: contactKeys.all
}); // Invalidates everything
queryClient.invalidateQueries({
queryKey: contactKeys.lists()
}); // Only list queries
Puisque les clés correspondent par préfixe, la hiérarchie vous permet d’annuler l’efficacité de manière large ou restreinte : contactKeys.all met à jour tout ce qui concerne les contacts, tandis que contactKeys.lists() ne touche qu’aux requêtes de type liste et laisse intactes les informations mémorisées.
Changer les données avec des mutations
Un crochet de mutation de base
Les écritures passent par useMutation. Le fait de l’envelopper dans un crochet permet de gérer les effets secondaires tels que les messages d’information à côté de la requête.
export function useDeleteContact() {
return useMutation({
mutationFn: (contactId: string) => deleteContact(contactId),
onSuccess: () => {
toast.success('Contact deleted successfully');
},
onError: () => {
toast.error('Failed to delete contact');
},
});
}
// Usage in components
function ContactCard({ contact }) {
const { mutate, isPending } = useDeleteContact();
return (
<Card>
<h3>{contact.name}</h3>
<button
onClick={() => mutate(contact.id)}
disabled={isPending}
>
{isPending ? 'Deleting...' : 'Delete'}
</button>
</Card>
);
}
onSuccess, onError et onSettled s’exécutent aux étapes correspondantes ; isPending facilite le désactivation du bouton tant que la requête est en cours d’exécution.
Déclarer ce que une mutation annule
Après une écriture, les requêtes concernées doivent être récupérées à nouveau. Au lieu d’appeler invalidateQueries dans chaque hook, une mutation peut déclarer ses cibles dans meta, et un seul gestionnaire global peut s’en occuper.
// In your mutation
export function useDeleteContact() {
return useMutation({
mutationFn: (contactId: string) => deleteContact(contactId),
meta: {
invalidates: [contactKeys.all],
},
});
}
// Global setup (one time, in main.tsx)
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
onSettled: async (data, error, variables, context) => {
const meta = context?.meta;
if (meta?.invalidates) {
await Promise.all(
meta.invalidates.map((queryKey) =>
queryClient.invalidateQueries({ queryKey })
)
);
}
},
},
},
});
L’idée est bonne, mais vérifiez le câblage par rapport à votre version. Dans la signature de la fonction de rappel présentée, le quatrième argument est la valeur retournée par onMutate, et non la mutation elle-même ; donc context?.meta ne trouvera pas les clés déclarées. De plus, une mutation qui définit sa propre fonction onSettled remplace cette fonction par défaut au lieu de s’exécuter en même temps qu’elle. Un emplacement plus fiable pour le gestionnaire est un MutationCache transmis au QueryClient : ses fonctions de rappel reçoivent l’objet de mutation (avec mutation.meta) et s’exécutent toujours en plus des fonctions de rappel spécifiques à chaque mutation. En TypeScript, pour pouvoir utiliser meta.invalidates, il est nécessaire de déclarer un type Meta personnalisé.
Gestion globale des erreurs
Les échecs transversaux, tels qu’une session expirée, doivent également être gérés en un seul endroit.
const queryClient = new QueryClient({
defaultOptions: {
mutations: {
onError: (error) => {
// Handle authentication globally
if (error.status === 401) {
logout();
navigate('/login');
}
// Handle network errors
if (error.message === 'Network Error') {
toast.error('Check your connection');
}
},
},
},
});
Toute mutation présente alors la même réponse en cas d’erreur 401 ou de panne réseau. La même précaution s’applique : la fonction useDeleteContact définie ci-dessus possède sa propre méthode onError qui remplace cette valeur par défaut, ce qui fait que la méthode onError de MutationCache reste le choix fiable. Notez également que error.status ainsi que le message 'Network Error' dépendent du client HTTP utilisé ; ce dernier correspond au message généré par Axios, tandis que fetch provoque une erreur de type TypeError.
Mises à jour optimistes
L’interface utilisateur optimiste affiche le résultat d’une écriture avant que le serveur ne le confirme. Il existe deux niveaux de mise en œuvre.
Niveau UI : cacher les éléments tant que leur suppression est en attente
useMutationState met à disposition les mutations en cours partout dans l’arborescence. En filtrant par clé et état, on obtient les IDs qui sont actuellement supprimés, que la liste peut simplement cacher.
function useContactsBeingDeleted() {
return useMutationState({
filters: {
mutationKey: ['deleteContact'],
status: 'pending'
},
select: (mutation) => mutation.state.variables,
});
}
function ContactsList() {
const { data: contacts } = useContacts();
const deletingIds = useContactsBeingDeleted();
// Filter out contacts being deleted
const visibleContacts = contacts.filter(
c => !deletingIds.includes(c.id)
);
return <List items={visibleContacts} />;
}
Rien ne change dans le cache, donc si la requête échoue, l’élément réapparaît automatiquement. Cette correspondance dépend du fait que la mutation comporte mutationKey: ['deleteContact'], valeur qui n’a pas été définie par l’hook précédent ; il faut la ajouter, sinon le filtre ne trouve rien.
Niveau du cache : modifier le cache et revenir en arrière en cas d’échec
L’approche la plus complète consiste à modifier directement la liste stockée dans le cache, afin que tous les composants qui la lisent soient mis à jour en même temps.
export function useDeleteContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteContact,
onMutate: async (contactId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({
queryKey: contactKeys.lists()
});
// Snapshot current value
const previousContacts = queryClient.getQueryData(
contactKeys.lists()
);
// Optimistically update
queryClient.setQueryData(
contactKeys.lists(),
(old) => old.filter(c => c.id !== contactId)
);
// Return rollback data
return { previousContacts };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousContacts) {
queryClient.setQueryData(
contactKeys.lists(),
context.previousContacts
);
}
},
onSettled: () => {
// Always refetch for consistency
queryClient.invalidateQueries({
queryKey: contactKeys.lists()
});
},
});
}
L’ordre est important. cancelQueries empêche tout rechargement en cours d’effet de supprimer la modification optimiste. L’aperçu est renvoyé depuis onMutate afin que onError puisse le restaurer. onSettled effectue un nouveau chargement que la requête ait réussi ou échoué, de sorte que le cache finisse par correspondre au serveur. Faites également attention à la clé : getQueryData et setQueryData sont identiques, donc si vos listes sont mémorisées sous contactKeys.list(filters), cibler contactKeys.lists() n’affecte rien ; setQueriesData met à jour chaque entrée correspondant à un préfixe. Protégez également la variable old, car la liste n’est pas encore forcément mémorisée.
Suspense pour les états de chargement
useSuspenseQuery garantit que la variable data est définie et transfère le traitement en attente à une limite Suspense de React.
// Change from useQuery to useSuspenseQuery
function ContactsList() {
const { data } = useSuspenseQuery(contactsQueryOptions);
// No isPending check needed!
return <Table data={data} />;
}
function ContactDetails({ id }) {
const { data } = useSuspenseQuery(contactQueryOptions(id));
return <Details contact={data} />;
}
// Centralized loading UI
function App() {
return (
<Suspense fallback={<AppSkeleton />}>
<ContactsList />
<ContactDetails id="123" />
</Suspense>
);
}
Une frontière remplace les curseurs dispersés par un seul squelette. Le compromis réside dans la granularité : cette frontière attend le plus lent des éléments enfants, il convient donc de la placer là où un état de chargement combiné a réellement du sens, et de précharger des données afin d’éviter les cascades de requêtes.
Les modèles combinés
Voici la fonctionnalité des contacts avec toutes les composantes assemblées : une usine de clés, des options paramétrables, une mutation de suppression qui déclare son invalidation et met à jour les données de manière optimiste, ainsi qu’une liste qui suspend le chargement et précharge des données.
// queries/contacts.ts
export const contactKeys = {
all: ['contacts'] as const,
lists: () => [...contactKeys.all, 'list'] as const,
list: (filters: Filters) => [...contactKeys.lists(), filters] as const,
detail: (id: string) => [...contactKeys.all, id] as const,
};
export const contactsQueryOptions = (filters: Filters) =>
queryOptions({
queryKey: contactKeys.list(filters),
queryFn: () => getContacts(filters),
});
export function useDeleteContact() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteContact,
mutationKey: ['deleteContact'],
meta: { invalidates: [contactKeys.all] },
onMutate: async (contactId) => {
await queryClient.cancelQueries({
queryKey: contactKeys.lists()
});
const previous = queryClient.getQueryData(
contactKeys.lists()
);
queryClient.setQueryData(
contactKeys.lists(),
(old) => old?.filter(c => c.id !== contactId)
);
return { previous };
},
onError: (err, variables, context) => {
if (context?.previous) {
queryClient.setQueryData(
contactKeys.lists(),
context.previous
);
}
},
});
}
// components/ContactsList.tsx
function ContactsList() {
const [page, setPage] = useState(1);
const queryClient = useQueryClient();
const { data } = useSuspenseQuery(
contactsQueryOptions({ page, pageSize: 20 })
);
const { mutate: deleteContact } = useDeleteContact();
// Prefetch next page
useEffect(() => {
queryClient.prefetchQuery(
contactsQueryOptions({ page: page + 1, pageSize: 20 })
);
}, [page, queryClient]);
return (
<Table
data={data.items}
onDelete={deleteContact}
pagination={{ page, onChange: setPage }}
/>
);
}
Le résultat est typé de bout en bout, rapide, et conserve les règles de cache dans un seul module. Un point à surveiller : avec useSuspenseQuery, toute modification de page suspend à nouveau le chargement et affiche l’alternative par défaut à chaque changement de page ; en enveloppant setPage dans startTransition, on maintient la page actuelle à l’écran pendant que la suivante se charge.
Points clés
- Associez chaque entrée de requête à sa clé ; cette règle simple empêche la plupart des bugs de cache.
- Définissez les requêtes en tant qu’objets
queryOptionsafin que les hooks, le préchargement et les lectures du cache partagent une source de type identique. - Adoptez tôt une fonction de génération de clés ; la correspondance par préfixe permet alors une invalidation précise.
- Centralisez l’invalidation et la gestion des erreurs, de préférence dans les callbacks de
MutationCache, afin que les callbacks par mutation ne les remplacent pas silencieusement. - Préférez un approche optimiste au niveau de l’interface pour les masquages simples, et une approche optimiste au niveau du cache, avec prise de snapshot et possibilité de revenir en arrière, lorsque de nombreux composants lisent les données.
Lectures complémentaires
- React Query et Redux : Reconsidérer l’état du serveur dans des applications grandes — Découvrez pourquoi une application de chat en production a utilisé TanStack Query plutôt que Redux pour gérer les données du serveur, et où Redux trouve encore sa place dans l’architecture React moderne.
- Envoyer des données avec RTK Query : Un guide pratique sur les mutations — Apprenez à utiliser builder.mutation() dans RTK Query pour envoyer des requêtes POST, gérer les états de chargement et d’erreur, ainsi que pour créer un composant de formulaire fonctionnel.