Use Cases (Mis à jour: 02/06/2026)

Claude Code et TanStack Query : guide pratique pour les données React

Implémentez TanStack Query avec Claude Code : query keys, staleTime, optimistic updates, SSR hydration et tests.

Claude Code et TanStack Query : guide pratique pour les données React

Demander à Claude Code “ajoute TanStack Query” donne souvent une page qui semble fonctionner, mais pas forcément un cache maintenable. Les query keys changent de forme selon les fichiers, staleTime devient un nombre magique, les mutations invalident trop large ou trop étroit, et l’hydratation SSR peut refaire un fetch immédiatement côté client.

Ce guide prend une liste de projets comme exemple et montre un flux de travail plus sûr avec TanStack Query v5 : conception des query keys, staleTime et gcTime, invalidation après mutation, optimistic updates, états loading/error, SSR hydration et tests avec réseau mocké. Les points d’API sont vérifiés avec les documents officiels TanStack : Query Keys, Important Defaults, Query Invalidation, Optimistic Updates, Server Rendering & Hydration et Testing.

Pour compléter ce sujet, lisez aussi développement React avec Claude Code, stratégies de test, MSW mocks et gestion d’état Zustand. TanStack Query doit gérer l’état serveur ; l’état temporaire d’interface reste plutôt dans React ou dans un store client ciblé.

Fixer le contrat avant l’édition

TanStack Query gère surtout le server state : les données dont la source de vérité est une API, une base de données ou un service backend. Ce n’est pas la même chose que le texte tapé dans un champ, l’ouverture d’une modale ou l’onglet courant.

Avant de lancer Claude Code, donnez-lui ce contrat :

DécisionExempleIntérêt pour la revue
Unité de query key["projects", "list", filters]Adresse de cache stable
FraîcheurListe 60 secondes, détail 5 minutesRefetch explicable
Après mutationInvalider prefix de liste et key détailPas d’ancienne UI après écriture
Optimistic updateChanger le statut immédiatementRollback possible
VérificationVitest, MSW, buildPreuve plutôt qu’impression

Une query key est l’adresse du cache. TanStack Query v5 attend une key en tableau au niveau supérieur, sérialisable et unique pour les données. Une key comme ["projects", "list", { status: "active" }] décrit donc les conditions de données, pas le nom du composant.

Cas d’usage concrets

Premier cas : une liste d’administration, par exemple utilisateurs, factures, tickets, projets ou produits. Les filtres search, status et page doivent entrer dans la query key. Demandez à Claude Code de normaliser les espaces et la casse avant de construire la key.

Deuxième cas : détail et édition. Le détail peut utiliser ["projects", "detail", id] avec un staleTime plus long qu’une liste. Après l’édition, invalidez le détail et le prefix des listes. invalidateQueries marque les queries comme stale et les refetch en arrière-plan si elles sont rendues.

Troisième cas : un toggle rapide, comme statut, favori, archivage ou assignation. L’optimistic update est utile, mais seulement avec cancel des refetches, snapshot du cache, rollback en erreur et invalidation finale.

Quatrième cas : SSR avec Next.js ou un autre framework. Prefetch serveur puis hydration peut améliorer le premier rendu, mais si les keys serveur/client diffèrent, ou si staleTime reste à 0, le client refetch immédiatement. Utilisez la même query factory des deux côtés.

Query keys, staleTime et gcTime

Prompt de départ :

Lis le projet React + TypeScript existant et implémente une liste projects avec TanStack Query v5.
Utilise des query keys sous forme de tableaux, des filters normalisés, staleTime 60 secondes pour les listes et gcTime 10 minutes.
Utilise queryOptions et placeholderData: keepPreviousData pour la pagination.
Regroupe fonctions API, types, query key factory et hook dans un module.
N’utilise pas l’ancien nom cacheTime.

Enregistrez ceci dans src/features/projects/projects.query.ts. staleTime indique combien de temps les données restent fraîches ; gcTime indique combien de temps une query inactive reste en cache. En v5, utilisez gcTime, pas cacheTime.

import {
  keepPreviousData,
  queryOptions,
  useQuery,
} from "@tanstack/react-query";

export type ProjectStatus = "all" | "active" | "paused";
export type EditableProjectStatus = Exclude<ProjectStatus, "all">;

export type Project = {
  id: string;
  name: string;
  status: EditableProjectStatus;
  updatedAt: string;
};

export type ProjectFilters = {
  status: ProjectStatus;
  search: string;
  page: number;
};

export type ProjectListResponse = {
  items: Project[];
  page: number;
  hasMore: boolean;
};

export type UpdateProjectStatusInput = {
  id: string;
  status: EditableProjectStatus;
};

function normalizeFilters(filters: ProjectFilters): ProjectFilters {
  return {
    status: filters.status,
    search: filters.search.trim().toLowerCase(),
    page: Math.max(1, filters.page),
  };
}

async function readJson<T>(response: Response): Promise<T> {
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }

  return (await response.json()) as T;
}

export async function fetchProjects(
  filters: ProjectFilters,
): Promise<ProjectListResponse> {
  const normalized = normalizeFilters(filters);
  const params = new URLSearchParams({
    status: normalized.status,
    search: normalized.search,
    page: String(normalized.page),
  });

  const response = await fetch(`/api/projects?${params.toString()}`);
  return readJson<ProjectListResponse>(response);
}

export async function fetchProject(id: string): Promise<Project> {
  const response = await fetch(`/api/projects/${id}`);
  return readJson<Project>(response);
}

export async function updateProjectStatus(
  input: UpdateProjectStatusInput,
): Promise<Project> {
  const response = await fetch(`/api/projects/${input.id}`, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ status: input.status }),
  });

  return readJson<Project>(response);
}

export const projectKeys = {
  all: ["projects"] as const,
  lists: () => [...projectKeys.all, "list"] as const,
  list: (filters: ProjectFilters) =>
    [...projectKeys.lists(), normalizeFilters(filters)] as const,
  details: () => [...projectKeys.all, "detail"] as const,
  detail: (id: string) => [...projectKeys.details(), id] as const,
};

export const projectQueries = {
  list: (filters: ProjectFilters) =>
    queryOptions({
      queryKey: projectKeys.list(filters),
      queryFn: () => fetchProjects(filters),
      staleTime: 60_000,
      gcTime: 10 * 60_000,
    }),
  detail: (id: string) =>
    queryOptions({
      queryKey: projectKeys.detail(id),
      queryFn: () => fetchProject(id),
      staleTime: 5 * 60_000,
      gcTime: 30 * 60_000,
    }),
};

export function useProjects(filters: ProjectFilters) {
  return useQuery({
    ...projectQueries.list(filters),
    placeholderData: keepPreviousData,
  });
}

Le point important est la normalisation avant la key. Évitez aussi staleTime: Infinity comme réflexe. Dans une application multiutilisateur, une autre personne peut modifier les mêmes données.

Mutation invalidation et optimistic update

Une mutation écrit côté serveur. Claude Code doit traiter l’échec, pas seulement le succès. Le flux robuste : cancel des queries touchées, snapshot, écriture optimiste, rollback en erreur, puis invalidation ciblée.

Créez src/features/projects/useUpdateProjectStatus.ts.

import type { QueryKey } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
  projectKeys,
  updateProjectStatus,
  type Project,
  type ProjectListResponse,
} from "./projects.query";

type ListSnapshot = [QueryKey, ProjectListResponse | undefined];

export function useUpdateProjectStatus() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updateProjectStatus,
    onMutate: async (input) => {
      await queryClient.cancelQueries({ queryKey: projectKeys.lists() });
      await queryClient.cancelQueries({ queryKey: projectKeys.detail(input.id) });

      const listSnapshots =
        queryClient.getQueriesData<ProjectListResponse>({
          queryKey: projectKeys.lists(),
        }) as ListSnapshot[];
      const detailSnapshot = queryClient.getQueryData<Project>(
        projectKeys.detail(input.id),
      );

      for (const [key, data] of listSnapshots) {
        if (!data) continue;

        queryClient.setQueryData<ProjectListResponse>(key, {
          ...data,
          items: data.items.map((project) =>
            project.id === input.id
              ? { ...project, status: input.status }
              : project,
          ),
        });
      }

      queryClient.setQueryData<Project>(
        projectKeys.detail(input.id),
        (current) =>
          current ? { ...current, status: input.status } : current,
      );

      return { listSnapshots, detailSnapshot };
    },
    onError: (_error, input, context) => {
      for (const [key, data] of context?.listSnapshots ?? []) {
        queryClient.setQueryData(key, data);
      }

      queryClient.setQueryData(projectKeys.detail(input.id), context?.detailSnapshot);
    },
    onSettled: async (_data, _error, input) => {
      await Promise.all([
        queryClient.invalidateQueries({ queryKey: projectKeys.lists() }),
        queryClient.invalidateQueries({ queryKey: projectKeys.detail(input.id) }),
      ]);
    },
  });
}

Les erreurs fréquentes : mettre à jour seulement le détail et oublier les listes visibles, ou invalider tout le cache sans filtre. Demandez à Claude Code de lister les keys invalidées dans le résumé du PR.

États loading et error

Le premier chargement, le refresh en arrière-plan, la mutation en cours et l’échec de mutation doivent être distingués. Voici un composant testable.

import { useState } from "react";
import {
  useProjects,
  type EditableProjectStatus,
  type ProjectFilters,
  type ProjectStatus,
} from "./projects.query";
import { useUpdateProjectStatus } from "./useUpdateProjectStatus";

const defaultFilters: ProjectFilters = {
  status: "all",
  search: "",
  page: 1,
};

function nextStatus(status: EditableProjectStatus): EditableProjectStatus {
  return status === "active" ? "paused" : "active";
}

function errorMessage(error: unknown): string {
  return error instanceof Error ? error.message : "Unknown error";
}

export function ProjectListPage({
  initialFilters = defaultFilters,
}: {
  initialFilters?: ProjectFilters;
}) {
  const [filters, setFilters] = useState<ProjectFilters>(initialFilters);
  const projectsQuery = useProjects(filters);
  const updateStatus = useUpdateProjectStatus();

  function setStatus(status: ProjectStatus) {
    setFilters((current) => ({ ...current, status, page: 1 }));
  }

  function setSearch(search: string) {
    setFilters((current) => ({ ...current, search, page: 1 }));
  }

  if (projectsQuery.isPending) {
    return <p role="status">Loading projects...</p>;
  }

  if (projectsQuery.isError) {
    return (
      <section role="alert">
        <h2>Projects could not be loaded</h2>
        <p>{errorMessage(projectsQuery.error)}</p>
        <button type="button" onClick={() => void projectsQuery.refetch()}>
          Retry
        </button>
      </section>
    );
  }

  const projects = projectsQuery.data.items;

  return (
    <section aria-labelledby="projects-title">
      <h2 id="projects-title">Projects</h2>

      <label>
        Search
        <input
          value={filters.search}
          onChange={(event) => setSearch(event.target.value)}
        />
      </label>

      <label>
        Status
        <select
          value={filters.status}
          onChange={(event) => setStatus(event.target.value as ProjectStatus)}
        >
          <option value="all">All</option>
          <option value="active">Active</option>
          <option value="paused">Paused</option>
        </select>
      </label>

      {projectsQuery.isFetching ? (
        <p role="status">Refreshing cached data...</p>
      ) : null}

      {updateStatus.isError ? (
        <p role="alert">Update failed: {errorMessage(updateStatus.error)}</p>
      ) : null}

      <ul>
        {projects.map((project) => {
          const next = nextStatus(project.status);

          return (
            <li key={project.id}>
              <strong>{project.name}</strong>{" "}
              <span aria-label={`status ${project.status}`}>
                {project.status}
              </span>
              <button
                type="button"
                disabled={updateStatus.isPending}
                onClick={() =>
                  updateStatus.mutate({ id: project.id, status: next })
                }
                aria-label={`${next} ${project.name}`}
              >
                Mark {next}
              </button>
            </li>
          );
        })}
      </ul>
    </section>
  );
}

Prompt de revue : “vérifie que loading initial, refresh background, échec de mutation, retry et état disabled sont distinguables”.

SSR et hydration

En SSR, créez un QueryClient par request et réutilisez la même query factory côté serveur et client. Une key différente suffit à perdre le cache hydraté.

import {
  dehydrate,
  HydrationBoundary,
  QueryClient,
} from "@tanstack/react-query";
import { ProjectListPage } from "@/features/projects/ProjectListPage";
import {
  projectQueries,
  type ProjectFilters,
  type ProjectStatus,
} from "@/features/projects/projects.query";

type PageProps = {
  searchParams?: {
    status?: string;
    search?: string;
    page?: string;
  };
};

function parseStatus(value: string | undefined): ProjectStatus {
  return value === "active" || value === "paused" ? value : "all";
}

export default async function ProjectsPage({ searchParams }: PageProps) {
  const filters: ProjectFilters = {
    status: parseStatus(searchParams?.status),
    search: searchParams?.search ?? "",
    page: Number(searchParams?.page ?? "1") || 1,
  };

  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60_000,
      },
    },
  });

  await queryClient.prefetchQuery(projectQueries.list(filters));

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ProjectListPage initialFilters={filters} />
    </HydrationBoundary>
  );
}

Le piège discret est la dérive de type : page: "1" côté serveur et page: 1 côté client produisent des keys différentes. Centralisez le parsing.

Tests avec réseau mocké

Mockez la frontière HTTP avec MSW, créez un QueryClient neuf par test et désactivez les retries.

import "@testing-library/jest-dom/vitest";
import type { ReactElement } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { ProjectListPage } from "./ProjectListPage";

const server = setupServer(
  http.get("/api/projects", () =>
    HttpResponse.json({
      items: [
        {
          id: "p-1",
          name: "Docs revamp",
          status: "active",
          updatedAt: "2026-06-02T00:00:00.000Z",
        },
      ],
      page: 1,
      hasMore: false,
    }),
  ),
  http.patch("/api/projects/:id", async ({ params, request }) => {
    const body = (await request.json()) as { status: "active" | "paused" };

    return HttpResponse.json({
      id: String(params.id),
      name: "Docs revamp",
      status: body.status,
      updatedAt: "2026-06-02T00:01:00.000Z",
    });
  }),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

function renderWithClient(ui: ReactElement) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        gcTime: Infinity,
      },
      mutations: {
        retry: false,
      },
    },
  });

  return {
    user: userEvent.setup(),
    ...render(
      <QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
    ),
  };
}

describe("ProjectListPage", () => {
  it("loads projects from the API", async () => {
    renderWithClient(<ProjectListPage />);

    expect(screen.getByRole("status")).toHaveTextContent("Loading projects");
    expect(await screen.findByText("Docs revamp")).toBeInTheDocument();
    expect(screen.getByLabelText("status active")).toBeInTheDocument();
  });

  it("rolls back an optimistic update when the mutation fails", async () => {
    server.use(
      http.patch("/api/projects/:id", () =>
        HttpResponse.json({ message: "boom" }, { status: 500 }),
      ),
    );

    const { user } = renderWithClient(<ProjectListPage />);

    await screen.findByText("Docs revamp");
    await user.click(
      screen.getByRole("button", { name: /paused docs revamp/i }),
    );

    await waitFor(() =>
      expect(screen.getByText(/Update failed:/)).toBeInTheDocument(),
    );
    expect(screen.getByLabelText("status active")).toBeInTheDocument();
  });
});

Commandes :

npm create vite@latest tanstack-query-claude-demo -- --template react-ts
cd tanstack-query-claude-demo
npm install @tanstack/react-query
npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event msw
npm pkg set scripts.test="vitest"
npm run test -- --run src/features/projects/ProjectListPage.test.tsx
npm run build

Pour cet article :

node scripts/check-code-fences.mjs
node scripts/check-updated-article-quality.mjs

Erreurs fréquentes et prompt sûr

Ne mettez pas de fonctions, instances de classe ou Date brut dans les query keys. Convertissez les dates en chaînes stables.

Ne confondez pas staleTime et gcTime. Le premier contrôle la fraîcheur et le refetch ; le second contrôle la durée de vie des queries inactives. Par défaut, les données sont stale et les queries inactives sont généralement collectées après environ cinq minutes.

N’utilisez pas l’invalidation globale par habitude. Préférez un prefix ou une key détail exacte. Ne dupliquez pas non plus la factory entre SSR et client.

Prompt conseillé :

Utilise uniquement TanStack Query v5. Utilise gcTime, pas cacheTime.
Sépare query key factory, fonctions API, hooks, UI et tests.
Pour la mutation, implémente cancel, snapshot, optimistic update, rollback et invalidation ciblée.
Vérifie que la key SSR prefetch et la key du hook client sont identiques.
Donne les commandes npm run test -- --run ProjectListPage.test.tsx et npm run build.

CTA et résultat testé

TanStack Query est un bon endroit pour standardiser l’usage de Claude Code, car les erreurs de cache sont discrètes et coûteuses. Pour démarrer, utilisez la cheatsheet gratuite. Pour des prompts et templates de revue réutilisables, consultez ClaudeCodeLab products. Pour appliquer ces règles dans un vrai dépôt d’équipe, passez par formation et conseil Claude Code.

Quand Masa a testé ce flux, le plus gros gain est venu du query key factory fixé avant l’UI. Commencer par l’interface a produit une normalisation incomplète, des keys SSR différentes et des listes non rafraîchies après mutation. En mettant query keys, staleTime, gcTime, rollback et tests MSW dans le premier prompt, le diff Claude Code était plus petit et la revue portait sur une question claire : quel cache change, et pourquoi.

#Claude Code #TanStack Query #React #données #cache
Gratuit

PDF gratuit: cheatsheet Claude Code

Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.

Nous protégeons vos données et n'envoyons pas de spam.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.