Accueil / Articles / Structurer une couche de données TanStack Query, des options de requête aux annulations

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.

2730 mots

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 queryOptions afin 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

  • Comment React-Redux décide de re-renderer : sélecteurs, égalité et hooks typés — Un guide d’étude sur les mécanismes internes de React-Redux : Provider, égalité avec useSelector, sélecteurs mémorisés, connect(), hooks typés avec withTypes(), ainsi que les cas limites rares.
  • UI optimiste sans désynchronisations : snapshots, retours en arrière et demandes annulées — Apprenez à créer des mises à jour optimistes qui restent correctes : état en snapshot, mise à jour instantanée, retour en arrière en cas d’échec, annulation des demandes obsolètes, et savoir quand ne pas utiliser ce modèle.
  • Concevoir des écrans React pour les états de chargement, vide, erreur et reessayage — Apprenez à modéliser tous les états dans lesquels un écran et un formulaire React peuvent se trouver, des squelettes et des requêtes de récupération jusqu’aux résultats vides, aux erreurs utiles et à la protection contre les soumissions doubles.
  • Construire un module React basé sur des fonctionnalités : Posts CRUD avec TanStack Query — Réorganisez une application React par fonctionnalité plutôt que par type de fichier, intégrez pleinement les opérations CRUD pour les posts avec TanStack Query et axios, et découvrez où ce modèle cesse de fonctionner efficacement.
  • Zustand vs TanStack Query Boundaries dans un monorepo Next.js en temps réel — Découvrez une règle claire de responsabilité pour l’état de l’interface utilisateur, l’état du serveur et les événements en temps réel dans un monorepo Next.js collaboratif utilisant Zustand, TanStack Query et des sockets.