Use Cases (Aktualisiert: 2.6.2026)

Claude Code und TanStack Query: Praxisguide für React-Daten und Cache

TanStack Query mit Claude Code sicher umsetzen: query keys, staleTime, optimistic updates, SSR hydration und Tests.

Claude Code und TanStack Query: Praxisguide für React-Daten und Cache

Wer Claude Code nur bittet, “TanStack Query einzubauen”, bekommt oft Code, der im Demo-Fall funktioniert, aber beim Review unscharf bleibt. Query keys sehen je nach Datei anders aus, staleTime ist eine Zahl ohne Begründung, Mutations invalidieren zu viel oder zu wenig, und SSR hydration löst nach dem ersten Render direkt einen zweiten Fetch aus.

Dieser Guide nutzt eine Projektliste als Beispiel und zeigt einen robusten Workflow für TanStack Query v5: query key Design, staleTime und gcTime, invalidation nach mutations, optimistic updates, loading/error states, SSR hydration und Tests mit gemocktem Netzwerk. Die Fakten zu den APIs sind mit den offiziellen TanStack-Dokumenten abgeglichen: Query Keys, Important Defaults, Query Invalidation, Optimistic Updates, Server Rendering & Hydration und Testing.

Als Ergänzung passen React-Entwicklung mit Claude Code, Testing-Strategien, MSW mocks und Zustand state management. TanStack Query sollte Serverdaten verwalten; kleiner temporärer UI-State bleibt besser in React oder einem klar begrenzten Client-Store.

Vor dem Editieren den Vertrag festlegen

TanStack Query ist für server state gedacht: Daten, deren Wahrheit in API, Datenbank oder Backend-Prozess liegt. Das ist nicht dasselbe wie ein Suchfeld während der Eingabe, ein offenes Modal oder ein ausgewählter Tab.

Gib Claude Code vor dem ersten Edit diese Tabelle:

EntscheidungBeispielWarum es im Review hilft
Einheit der query key["projects", "list", filters]Cache-Adresse bleibt stabil
FreshnessListen 60 Sekunden, Detail 5 MinutenRefetch ist erklärbar
Nach mutationListen-prefix und Detail-key invalidierenKeine alte UI nach Writes
Optimistic updateStatuswechsel sofort anzeigenRollback bei Fehler möglich
VerifikationVitest, MSW, buildReview basiert auf Belegen

Eine query key ist die Cache-Adresse. TanStack Query v5 erwartet, dass query keys auf oberster Ebene Arrays sind, serialisierbar bleiben und die Daten eindeutig beschreiben. ["projects", "list", { status: "active" }] beschreibt also Datenbedingungen, nicht den Komponentennamen.

Reale Einsatzfälle

Der erste Einsatzfall ist eine Admin-Liste: Nutzer, Rechnungen, Tickets, Projekte oder Produkte. Search, Status und Page gehören in die query key. Claude Code sollte Leerzeichen und Groß-/Kleinschreibung normalisieren, bevor diese Filter Teil der key werden.

Der zweite Einsatzfall ist Detail plus Bearbeitung. Details liegen etwa unter ["projects", "detail", id] und dürfen oft länger fresh bleiben als Listen. Nach erfolgreicher Bearbeitung invalidierst du Detail-key und Listen-prefix. invalidateQueries markiert passende queries als stale und refetcht sie im Hintergrund, wenn sie gerade gerendert werden.

Der dritte Einsatzfall ist ein schneller Toggle wie Status, Favorit, Archiv oder Zuweisung. Optimistic updates sind hier sinnvoll, aber nur mit cancel laufender refetches, snapshot des alten Cache, rollback bei Fehler und finaler invalidation.

Der vierte Einsatzfall ist SSR in Next.js oder ähnlichen Frameworks. Server-prefetch plus hydration kann den ersten Render verbessern. Wenn Server und Client unterschiedliche keys bauen oder staleTime bei 0 bleibt, refetcht der Client sofort. Nutze dieselbe query factory auf beiden Seiten.

Query keys, staleTime und gcTime

Ein präziser Prompt:

Lies das bestehende React + TypeScript Projekt und implementiere eine projects-Liste mit TanStack Query v5.
Nutze query keys als top-level arrays, normalisierte filters, staleTime 60 Sekunden für Listen und gcTime 10 Minuten.
Nutze queryOptions und placeholderData: keepPreviousData für Pagination.
Lege API-Funktionen, Typen, query key factory und Hook in ein Modul.
Verwende nicht den alten Namen cacheTime.

Speichere dies als src/features/projects/projects.query.ts. staleTime beschreibt, wie lange Daten fresh sind. gcTime beschreibt, wie lange inaktive query data im Cache bleibt. In v5 heißt die Option gcTime, nicht 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,
  });
}

Wichtig ist die Normalisierung vor der key. Setze nicht aus Gewohnheit überall staleTime: Infinity. In Mehrbenutzer-Dashboards kann jemand anders dieselben Daten ändern.

Mutation invalidation und optimistic updates

Eine mutation schreibt auf den Server. Claude Code muss deshalb nicht nur den Erfolgsfall, sondern auch Fehler und rollback implementieren. Der robuste Ablauf lautet: betroffene queries canceln, snapshot nehmen, optimistisch schreiben, bei Fehler zurückrollen und danach gezielt invalidieren.

Erstelle 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) }),
      ]);
    },
  });
}

Typische Fehler sind: nur Detail-cache aktualisieren und sichtbare Listen vergessen, oder mit invalidateQueries() ohne Filter den gesamten Cache treffen. Lass Claude Code im PR-Text alle invalidierten keys nennen.

Loading und Error States

Initiales Laden, background refresh, laufende mutation und fehlgeschlagene mutation sind unterschiedliche Zustände. Der folgende Code macht sie sichtbar und testbar.

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>
  );
}

Ein sinnvoller Review-Prompt lautet: “Prüfe, ob initial loading, background refresh, mutation failure, retry und disabled state klar unterscheidbar sind.”

SSR und hydration

Bei SSR wird pro Request ein neuer QueryClient erstellt. Server und Client müssen dieselbe query factory verwenden, sonst kann der hydrierte Cache nicht wiederverwendet werden.

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>
  );
}

Der leise Fehler ist type drift: page: "1" auf dem Server und page: 1 im Client ergeben verschiedene keys. Zentralisiere Parsing und key factory.

Tests mit gemocktem Netzwerk

Mocke HTTP mit MSW, erstelle pro Test einen frischen QueryClient und schalte retries aus.

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();
  });
});

Verifikation:

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

Für diesen Artikel:

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

Fehlerbilder und sicherer Prompt

Lege keine Funktionen, Klasseninstanzen oder rohen Date-Objekte in query keys. Nutze stabile Strings. Verwechsle staleTime und gcTime nicht: ersteres steuert Freshness und refetch, letzteres die Lebensdauer inaktiver queries. Standardmäßig gelten Daten als stale und inaktive queries werden nach etwa fünf Minuten gesammelt.

Nutze globale invalidation nicht als Gewohnheit. queryClient.invalidateQueries() ohne Filter verdeckt schwaches key Design. Dupliziere außerdem keine factory zwischen SSR und Client.

Nutze nur TanStack Query v5. Nutze gcTime, nicht cacheTime.
Trenne query key factory, API functions, hooks, UI und tests.
Implementiere in der mutation cancel, snapshot, optimistic update, rollback und gezielte invalidation.
Prüfe, dass SSR prefetch key und client hook key identisch sind.
Zeige npm run test -- --run ProjectListPage.test.tsx und npm run build als Verifikation.

CTA und Praxisergebnis

TanStack Query eignet sich gut, um Claude-Code-Regeln zu standardisieren, weil Cachefehler leise und teuer sind. Starte alleine mit dem kostenlosen Cheatsheet. Für wiederverwendbare Prompts und Review-Templates nutze ClaudeCodeLab products. Teams, die query key Regeln, Berechtigungen, Review-Policy und Rollout im echten Repository verankern wollen, sollten Claude Code Training und Beratung nutzen.

Masa hat beim Testen vor allem gelernt: zuerst die query key factory festlegen, dann UI bauen. Die UI-first-Version hatte unvollständige Filter-Normalisierung, unterschiedliche SSR keys und Listen, die nach mutations alt blieben. Mit query keys, staleTime, gcTime, rollback und MSW-Tests im ersten Prompt wurde der Claude-Code-Diff kleiner und das Review konkreter.

#Claude Code #TanStack Query #React #Datenabruf #Cache
Kostenlos

Kostenloses PDF: Claude-Code-Cheatsheet

E-Mail eintragen und eine Seite mit Befehlen, Review-Gewohnheiten und sicheren Workflows herunterladen.

Wir schützen Ihre Daten und senden keinen Spam.

Masa

Über den Autor

Masa

Engineer für praktische Claude-Code-Workflows und Team-Einführung.