Claude CodeでTanStack Queryを安全に実装する実践ガイド
query key設計、staleTime、楽観的更新、SSR、テストまでClaude CodeでTanStack Queryを安全に実装する手順。
Claude Codeに「TanStack Queryでデータ取得を作って」とだけ頼むと、見た目は動いてもキャッシュ境界が曖昧なコードになりがちです。query keyが毎回違う形になり、staleTimeの理由が残らず、mutation後にどの一覧を再取得すべきかもレビューしづらくなります。
この記事では、React + TypeScriptの管理画面を題材に、Claude Codeへ安全に依頼するための設計、コピペで使えるTanStack Query v5コード、SSR/hydrationの注意点、MSWを使ったテストまでを一気通貫でまとめます。公式仕様はTanStackのQuery Keys、Important Defaults、Query Invalidation、Optimistic Updates、Server Rendering & Hydration、Testingを基準にします。
関連する前提として、React側の分割はClaude Code React開発、テスト全体の考え方はClaude Codeテスト戦略、API mockはClaude Code MSWモック、クライアント状態との切り分けはClaude Code Zustandも合わせて確認してください。
Claude Codeに渡す境界を先に決める
TanStack Queryが扱うのは、主にサーバー状態です。サーバー状態とは、ブラウザではなくAPIやDB側が正とするデータのことです。検索フォームの入力途中、モーダルの開閉、選択中タブのような一時的なUI状態とは分けて考えます。
Claude Codeへ依頼する前に、次の表を埋めると出力が安定します。
| 決めること | 例 | レビューで見る理由 |
|---|---|---|
| query keyの単位 | ["projects", "list", filters] | キャッシュの住所がぶれない |
| データの鮮度 | 一覧は60秒、詳細は5分 | 何を再取得するか説明できる |
| mutation後の処理 | 一覧prefixと詳細keyをinvalidate | 古いUIを残さない |
| 楽観的更新 | ステータス切替だけ先に反映 | 失敗時にrollbackできる |
| 検証 | Vitest、MSW、build | Claude Codeの差分を証拠で確認できる |
query keyは「キャッシュの住所」です。TanStack Query v5の公式ドキュメントでは、query keyはトップレベルが配列で、JSON.stringifyでき、対象データに対して一意である必要があると説明されています。つまり、["projects", "list", { status: "active" }]のように、画面名ではなくデータの条件を含めて設計します。
3つ以上の実務ユースケース
1つ目は、管理画面の一覧です。ユーザー、請求、問い合わせ、プロジェクトのような一覧では、検索語、ステータス、ページ番号をquery keyに入れます。Claude Codeには「URLに出る条件だけをkeyに入れる」「空文字は正規化する」と指示すると、同じ一覧なのに別キャッシュになる事故を減らせます。
2つ目は、詳細画面と編集画面です。詳細は["projects", "detail", id]で持ち、編集成功後に詳細keyと一覧prefixを無効化します。TanStack QueryのinvalidateQueriesは対象queryをstale扱いにし、表示中ならバックグラウンドで再取得します。ここを曖昧にすると、編集後に一覧だけ古い、詳細だけ古い、というレビューしづらい不具合になります。
3つ目は、ステータス切替やお気に入り登録です。クリック後すぐUIを変えたい場合は楽観的更新を使います。ただし「先に変える」だけでは危険です。進行中のrefetchをcancelし、変更前のcacheをsnapshotし、失敗時に戻し、最後にinvalidateする流れまでセットでClaude Codeに要求します。
4つ目は、SSRやNext.js App Routerの初期表示です。SSRではサーバーでprefetchしたデータをclientへdehydrate/hydrateします。公式ガイドでも、SSRではclient側で即時再取得が起きないようstaleTimeを0より大きくする例が示されています。初期表示を速くしても、hydration直後に二重fetchしては意味がありません。
query keyとstaleTime/gcTimeの実装
まずClaude Codeへ次のように依頼します。専門用語も短く定義しておくと、レビュー観点が揃います。
既存のReact + TypeScriptコードを読んで、TanStack Query v5でprojects一覧を実装してください。
query keyはトップレベル配列、filtersは正規化、一覧はstaleTime 60秒、gcTime 10分。
queryOptionsを使い、useQuery側ではplaceholderData: keepPreviousDataを使ってページ切替のちらつきを抑えてください。
API関数、型、query key factory、hookを1ファイルにまとめ、古いcacheTimeという名前は使わないでください。
次のコードはsrc/features/projects/projects.query.tsとして貼り付けられます。staleTimeは「freshとして扱う時間」、gcTimeは使われなくなったqueryがcacheに残る時間です。v5では旧記事で見かけるcacheTimeではなくgcTimeを使います。
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,
});
}
この設計で重要なのは、filtersをquery keyへ入れる前に正規化している点です。" Billing "と"billing"を別cacheにしないためです。また、staleTime: Infinityを最初から乱用しないことも大切です。別ユーザーが同じデータを更新する管理画面では、古い情報を長時間fresh扱いするほうが危険な場合があります。
mutation invalidationと楽観的更新
mutationは「サーバーへ変更を送る操作」です。Claude Codeにmutationを書かせるときは、成功時だけでなく失敗時のcache復元まで指示します。公式のOptimistic Updatesでも、onMutateで進行中のqueryをcancelし、snapshotを取り、onErrorで戻し、onSettledでinvalidateする流れが示されています。
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) }),
]);
},
});
}
落とし穴は、detailだけ更新して一覧を忘れること、または一覧を全部invalidateして不要な再取得を増やすことです。上の例ではprojectKeys.lists()というprefixで一覧だけを対象にしています。請求や監査ログなど、変更の影響範囲が広い機能では、Claude Codeに「どのquery keyをinvalidateしたかをPR本文に列挙して」と依頼するとレビューしやすくなります。
loading/error状態を画面に出す
UIではisPending、isError、isFetching、mutationのisPendingを分けます。初回ロード、バックグラウンド再取得、更新中、更新失敗は読者にも運用者にも意味が違うためです。
src/features/projects/ProjectListPage.tsxの例です。
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>
);
}
ここでClaude Codeに追加レビューを頼むなら、「初回loadingとbackground fetchingが同じ文言になっていないか」「mutation失敗が画面に残るか」「buttonのdisabledが広すぎて別行の操作まで止めていないか」を見せます。実務ではここでアクセシビリティや文言品質も一緒に直すと手戻りが減ります。
SSRとhydrationの注意点
Next.jsなどでSSRする場合は、サーバー側で作ったQueryClientをリクエスト間で共有しないことが重要です。別ユーザーのcacheを混ぜないため、requestごとに新しいclientを作ります。また、SSR直後の二重fetchを避けるため、通常は0より大きいstaleTimeを設定します。
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>
);
}
SSRの落とし穴は、server側とclient側でquery keyが少しだけ違うことです。serverではpage: 1、clientではpage: "1"のように型が違うだけでも別cacheになります。だからこそ、filtersの正規化関数を1か所に置き、Claude Codeに「serverとclientで同じquery factoryを使う」と明示します。
MSWでnetworkをmockしてテストする
TanStack Queryのテストでは、retryを切ると失敗が速く見えます。公式Testingガイドでも、test用QueryClientでretry: falseを設定する例が紹介されています。Jestで終了しないtimerに悩む場合はgcTime: Infinityも選択肢になります。
src/features/projects/ProjectListPage.test.tsxの例です。
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();
});
});
手元で確認する最小コマンドは次の通りです。
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
この記事自体を更新した後は、サイト側でも次の確認を行います。
node scripts/check-code-fences.mjs
node scripts/check-updated-article-quality.mjs
失敗例とClaude Codeへの安全な頼み方
失敗例の1つ目は、query keyに関数やDateや未正規化のobjectを入れることです。公式上、query keyはserializableである必要があります。Dateをそのまま入れるより、ISO文字列や日付文字列に変換します。
2つ目は、staleTimeとgcTimeを混同することです。staleTimeは再取得判断に関わり、gcTimeはinactive queryの破棄タイミングに関わります。デフォルトではquery dataはstale扱いで、inactive queryは通常5分後にgarbage collectされます。Claude Codeには「なぜその数値にしたかをコメントではなく本文やPR説明に残して」と依頼すると、後から変更しやすくなります。
3つ目は、mutation成功時にinvalidateQueries()を引数なしで呼ぶことです。全cacheを無効化すると動いているように見えますが、画面が増えるほど余計なnetwork requestが増えます。prefixとexactの使い分けをClaude Codeに確認させます。
4つ目は、SSRでserverとclientのkey factoryを別々に作ることです。これも二重fetchやhydration後のちらつきにつながります。query key factoryは共通moduleに置き、server page、client hook、testから同じものを使います。
5つ目は、テストで本物のAPIやタイミングに依存することです。MSWでHTTP境界をmockし、test用QueryClientを毎回新しく作ります。retryを切らずに失敗系をテストすると、1つのテストが無駄に遅くなります。
Claude Codeへの安全な依頼文は、次のように具体化します。
TanStack Query v5だけを使ってください。cacheTimeは使わずgcTimeを使います。
query key factory、API関数、hook、UI、testを分けてください。
mutationではonMutateでcancel、snapshot、optimistic update、onError rollback、onSettled invalidateを実装してください。
SSRで使うqueryOptionsとclient hookのquery keyが一致しているかレビューしてください。
最後に npm run test -- --run ProjectListPage.test.tsx と npm run build の想定結果を示してください。
自然なCTAと実際に試した結果
TanStack Queryは、Claude Code導入で差が出やすい領域です。単発のコード生成ではなく、query key、cache時間、失敗時の戻し方、test client、SSR境界までチームの型に落とす必要があります。個人で確認するなら無料チートシートから始め、繰り返し使うpromptやレビュー観点を整えるなら教材・テンプレートを見てください。実リポジトリでClaude Codeの運用、権限、レビュー、収益導線まで設計したい場合はClaude Code研修・導入相談が次のステップです。
この記事で紹介した内容を実際に試した結果、Masaが一番手戻りを減らせたのは「query key factoryを先に固定する」ことでした。最初にUIから作ると、検索条件の正規化、SSRのkey不一致、mutation後の一覧更新漏れが後から見つかりました。逆に、query key、staleTime、gcTime、mutationのrollback、MSWテストを最初の依頼に含めると、Claude Codeの差分は小さく、レビューでは「どのcacheを意図的に更新したか」を中心に確認できました。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。