この記事は公式ドキュメント(Optimistic Updates、Mutations、TypeScript、Advanced SSR)の最新版を一次情報として、実務で「どの場面で・どう書くか」を判断できるところまで踏み込みます。
1. 前提:なぜ TanStack Query なのか(サーバー状態 vs クライアント状態)
多くのバグは「サーバーから取得したデータ」を useState や Redux にコピーして手で同期させようとすることから生まれます。リモートのデータは本質的に、
- 自分が所有していない(他のユーザーや別タブが書き換える)
- 取得した瞬間から古くなりうる(stale になる)
- 非同期で、失敗・再試行・キャンセルが起きる
という性質を持ちます。TanStack Query はこの「サーバー状態」をキャッシュとして一元管理し、再フェッチ・重複排除・バックグラウンド更新・エラー回復を自動化します。逆に「モーダルの開閉」「フォームの入力途中の値」などのクライアント状態は、引き続き useState や軽量ストアで持つのが正解です。役割を混同しないこと——これが設計の起点です。
staleTime と gcTime の違い(最頻出の混乱ポイント)
| 設定 | 意味 | デフォルト | 主な用途 |
|---|---|---|---|
staleTime | データが「新鮮」とみなされる期間。この間は再フェッチしない | 0 | 過剰なリクエストを抑える。SSRでは必須級 |
gcTime | 非アクティブなキャッシュをメモリから破棄するまでの時間 | 5分 | メモリ使用量とUX(戻った時の即表示)の調整 |
要点は「staleTime はネットワーク回数を、gcTime はメモリ寿命を制御する」こと。デフォルトの staleTime: 0 は「マウントや再フォーカスのたびに裏で再フェッチ」を意味します。これは安全側の挙動ですが、SSR やコストを気にする画面では明示的に staleTime を引き上げます(後述の App Router で重要)。
ここから先は基礎 API を理解している前提で、本番運用に耐えるキャッシュ設計に絞って解説します。
2. 【2026年の最新仕様】Mutation コールバックの新シグネチャと context.client
まず、多くの既存記事と差がつく最重要アップデートです。v5 の最新版では、Mutation のライフサイクルコールバックの引数が次のように整理されました。
onMutate: (variables, context) => onMutateResult | Promise<onMutateResult | void>
onSuccess: (data, variables, onMutateResult, context) => unknown
onError: (error, variables, onMutateResult, context) => unknown
onSettled: (data, error, variables, onMutateResult, context) => unknown
ポイントは2つです。
- 末尾に
context(MutationFunctionContext)が追加された。 ここにはcontext.client(QueryClient本体)・context.meta・context.mutationKeyが入っています。つまりコールバック内でuseQueryClient()を呼ばなくても、context.clientからキャッシュ操作ができます。 onMutateの戻り値は独立したonMutateResult引数になった。 以前はonError(err, variables, context)の第3引数がこの戻り値でしたが、名前がonMutateResultに明確化され、本物のcontextは第4引数に移りました。
既存コードは壊れるのか?
いいえ。onError / onSettled の第1〜第3引数の位置は不変(第3引数は従来どおり onMutate の戻り値)です。追加された第4引数の context を使わなければ、既存コードはそのまま動きます。新規実装では context.client を使うとコードがすっきりし、依存(useQueryClient のフック呼び出し)も減ります。
// 旧来の書き方(今も動く)
const queryClient = useQueryClient();
useMutation({
mutationFn: updateTodo,
onSettled: () => queryClient.invalidateQueries({ queryKey: ["todos"] }),
});
// 新シグネチャ:useQueryClient 不要、context.client を使う
useMutation({
mutationFn: updateTodo,
onSettled: (_data, _err, _vars, _onMutateResult, context) =>
context.client.invalidateQueries({ queryKey: ["todos"] }),
});
この context.client を前提に、以降の楽観的更新を最新仕様で組み立てます。
3. 型安全の起点:queryOptions ヘルパーとクエリキー設計
公式が TypeScript ガイドで最初に勧めるのが queryOptions ヘルパーです。クエリの定義(queryKey + queryFn + オプション)を1か所に集約し、useQuery / prefetchQuery / getQueryData のすべてに型と設定を流すための仕組みです。これが DRY(単一の正)と型安全の両方を同時に解決します。
// lib/todos/queries.ts
import { queryOptions } from "@tanstack/react-query";
import { fetchTodos, fetchTodo } from "./api";
import type { Todo, TodoFilters } from "./types";
// クエリキーファクトリ:キーの命名を一元管理し、タイポと不整合を排除する
export const todoKeys = {
all: ["todos"] as const,
lists: () => [...todoKeys.all, "list"] as const,
list: (filters: TodoFilters) => [...todoKeys.lists(), filters] as const,
details: () => [...todoKeys.all, "detail"] as const,
detail: (id: number) => [...todoKeys.details(), id] as const,
};
// queryOptions に定義を集約。戻り値は型情報を保持する
export const todoListOptions = (filters: TodoFilters) =>
queryOptions({
queryKey: todoKeys.list(filters),
queryFn: () => fetchTodos(filters),
staleTime: 60_000,
});
export const todoDetailOptions = (id: number) =>
queryOptions({
queryKey: todoKeys.detail(id),
queryFn: () => fetchTodo(id),
});
呼び出し側は1行。しかも getQueryData が戻り値の型を自動で知っているため、ジェネリクスの手書きが不要になります。
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { todoListOptions, todoKeys } from "@/lib/todos/queries";
function TodoList({ filters }: { filters: TodoFilters }) {
const { data } = useQuery(todoListOptions(filters)); // data: Todo[] | undefined(自動推論)
// ...
}
// キャッシュ直接読み取りも型安全
const queryClient = useQueryClient();
const cached = queryClient.getQueryData(todoListOptions(filters).queryKey);
// ^? Todo[] | undefined — ジェネリクス指定なしで型がつく
なぜこれが効くのか
- クエリキーの構造(
["todos", "list", filters]という階層)がtodoKeysに集約され、invalidateQueries({ queryKey: todoKeys.lists() })のようにプレフィックス無効化が意図どおり効きます(後述)。 - 文字列リテラルのキー直書きが消え、リファクタ耐性が上がります(ETC: Easy To Change)。
staleTimeなどの設定も定義側に集約されるため、サーバーとクライアントで設定がズレません。
エラー型について:
errorはデフォルトでError型です。アプリ全体で統一したい場合はRegisterインターフェースでdefaultErrorをグローバル登録できます。安易なunknown放置やanyキャストは避け、AxiosErrorなどは型ナローイングで絞り込みます。
4. 外科手術的なキャッシュ無効化:invalidateQueries の精密制御
invalidateQueries({ queryKey: ["todos"] }) は便利ですが、["todos"] で始まるすべてのクエリ(["todos", "list"]、["todos", "detail", 1] …)を一括で stale 化し、アクティブなものを再フェッチします。意図しない一斉再フェッチはパフォーマンスの落とし穴です。次の4つを使い分けて「必要な範囲だけ」無効化します。
4-1. マッチング戦略(prefix / 完全一致 / 変数指定 / predicate)
// プレフィックス一致:["todos"] で始まるすべて
queryClient.invalidateQueries({ queryKey: todoKeys.all });
// 変数まで指定:特定フィルタのリストだけ
queryClient.invalidateQueries({ queryKey: todoKeys.list({ type: "done" }) });
// 完全一致:そのキー自身のみ(子孫は対象外)
queryClient.invalidateQueries({ queryKey: todoKeys.all, exact: true });
// predicate:各クエリインスタンスを評価する最も柔軟な方法
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "todos" &&
typeof query.queryKey[1] === "object" &&
(query.queryKey[1] as { version?: number }).version! >= 10,
});
4-2. ドメインロジックで無効化する predicate
predicate はクエリキーだけでなく query.meta などインスタンスの状態で判定できます。「機密データだけログアウト時に破棄する」のような、キー構造に依存しない無効化が書けます。
// 定義側:meta でドメインの意味づけを付与
export const useSensitiveTodos = () =>
useQuery({
queryKey: todoKeys.list({ type: "sensitive" }),
queryFn: fetchSensitiveTodos,
meta: { cacheType: "sensitive" },
});
// ログアウト時:meta が sensitive のクエリだけを無効化
function handleLogout() {
queryClient.invalidateQueries({
predicate: (query) => query.meta?.cacheType === "sensitive",
});
}
4-3. refetchType で「いつ再フェッチするか」を制御する
invalidateQueries はデフォルト(refetchType: "active")では今マウントされているクエリだけを再フェッチし、非アクティブなものは「次にマウントされた時」に回します。先読みしたい・抑えたいを明示できます。
refetchType | 挙動 | 使いどころ |
|---|---|---|
"active"(既定) | 表示中のクエリのみ即再フェッチ | 通常はこれで十分 |
"inactive" | 非表示のクエリのみ再フェッチ | 裏で別画面を先に温めたい |
"all" | 表示・非表示すべて再フェッチ | 遷移先のローディングを消したい先読み |
"none" | stale 化するが再フェッチしない | コスト最優先。次回アクセス時にまとめて更新 |
// 設定更新後、非表示のダッシュボードも裏で温めておく(遷移時のスピナーを消す)
queryClient.invalidateQueries({ queryKey: ["dashboard"], refetchType: "all" });
5. 楽観的更新(Optimistic Updates)— 公式が示す2つの正攻法
楽観的更新は「サーバー応答を待たずに UI を即更新」する手法で、UX を劇的に改善します。v5 の公式ドキュメントは2つのアプローチを提示しており、どちらを選ぶかは「更新結果を画面の何か所で見せるか」で決まります。
5-1. UI ベース(推奨:表示箇所が1か所なら最小コード)
キャッシュを書き換えず、useMutation が返す variables(送信中の入力値)をそのまま仮表示する方法です。コードが圧倒的に少なく、ロールバック処理が不要です。
function AddTodo() {
const { mutate, isPending, variables, isError } = useMutation({
mutationFn: (text: string) => createTodo(text),
onSettled: () =>
queryClient.invalidateQueries({ queryKey: todoKeys.lists() }),
});
return (
<>
<ul>
{todos.map((t) => (
<li key={t.id}>{t.text}</li>
))}
{/* 送信中は variables を半透明で仮表示。失敗時は再送ボタンを出す */}
{isPending && <li style={{ opacity: 0.5 }}>{variables}</li>}
{isError && (
<li style={{ color: "red" }}>
{variables}{" "}
<button onClick={() => mutate(variables!)}>再試行</button>
</li>
)}
</ul>
<button onClick={() => mutate("新しいタスク")}>追加</button>
</>
);
}
別コンポーネントからこの「送信中の値」を参照したい場合は、mutationKey を付けて useMutationState で拾います。
const pendingTexts = useMutationState<string>({
filters: { mutationKey: ["addTodo"], status: "pending" },
select: (mutation) => mutation.state.variables as string,
});
5-2. キャッシュベース(複数箇所に反映するならこちら)
「リスト・詳細・バッジなど複数の場所に即時反映したい」場合は、キャッシュを直接書き換えます。ここで安全なロールバックが要点になります。新シグネチャ(context.client / onMutateResult)で書くとこうなります。
// lib/todos/mutations.ts
import { useMutation } from "@tanstack/react-query";
import { updateTodo } from "./api";
import { todoKeys } from "./queries";
import type { Todo, UpdateTodoInput } from "./types";
export const useToggleTodo = () =>
useMutation({
mutationFn: (input: UpdateTodoInput) => updateTodo(input),
// ① Mutation 実行直前。context.client から QueryClient にアクセス
onMutate: async (input, context) => {
const key = todoKeys.lists();
// 1-1. 進行中の再フェッチを止める(楽観更新がサーバー応答で上書きされるのを防ぐ)
await context.client.cancelQueries({ queryKey: key });
// 1-2. ロールバック用に現在のキャッシュを退避
const previousTodos = context.client.getQueryData<Todo[]>(key);
// 1-3. キャッシュをイミュータブルに楽観更新
context.client.setQueryData<Todo[]>(key, (old) =>
(old ?? []).map((todo) =>
todo.id === input.id ? { ...todo, ...input } : todo,
),
);
// 1-4. 戻り値は onMutateResult として onError / onSettled に渡る
return { previousTodos };
},
// ② 失敗時:退避しておいたスナップショットへ確実に戻す
onError: (_err, _input, onMutateResult, context) => {
if (onMutateResult?.previousTodos) {
context.client.setQueryData(todoKeys.lists(), onMutateResult.previousTodos);
}
},
// ③ 成否に関わらず、最後はサーバーの「正」と同期する
onSettled: (_data, _err, _input, _onMutateResult, context) => {
context.client.invalidateQueries({ queryKey: todoKeys.lists() });
},
});
なぜこの順序が「本番品質」なのか:
cancelQueriesを先頭で呼ぶ理由:これを省くと、楽観更新の直後に走った再フェッチが古いデータで UI を上書きし、「更新 → 一瞬戻る → 応答で再更新」というちらつき(flicker)が出ます。- スナップショットを
onMutateResult経由で渡す理由:onError内で改めてgetQueryDataを呼ぶと、すでに楽観更新された後の値を拾ってしまいます。onMutate開始時点の値を退避しておくことだけが、確実なロールバックを保証します。 onSettledで必ず無効化する理由:成功・失敗どちらでも最後にサーバーと同期することで、楽観更新の失敗が画面に残り続ける事故を防ぎます(回復性の担保)。
どちらを使う?(判断基準)
| 状況 | 推奨アプローチ |
|---|---|
| 更新結果を見せるのが1か所だけ | UI ベース(5-1) |
| 同じ更新を複数の画面・コンポーネントに反映 | キャッシュベース(5-2) |
| ロールバックの複雑さを避けたい | UI ベース(5-1) |
| 詳細・一覧・集計など派生データの整合が必要 | キャッシュベース(5-2) |
6. API を介さないキャッシュ直接更新:setQueryData
サーバー状態を変えない「純粋なクライアント側の表示変更」——並び替え、モーダル内の一時編集など——は、useMutation を使うのは過剰です。setQueryData で直接キャッシュを書き換えるのが正解です。
import { arrayMove } from "@dnd-kit/sortable";
import type { DragEndEvent } from "@dnd-kit/core";
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
// updater 関数を渡し、必ずイミュータブルに新しい配列を返す
queryClient.setQueryData<Todo[]>(todoKeys.lists(), (old) => {
if (!old) return old;
const from = old.findIndex((t) => t.id === active.id);
const to = old.findIndex((t) => t.id === over.id);
return arrayMove(old, from, to); // 非破壊的に並び替え
});
}
押さえるべき原則:
useMutationとの使い分け:これはサーバーの「更新」ではなく表示状態の変更です。useMutationを使うと不要なisPending状態やmutationKey管理を招き、意味論的にも誤りです。- 必ず updater 関数を使う:
setQueryData(key, newData)で値を直接渡すのではなくsetQueryData(key, (old) => ...)を使うと、競合状態(直前の別更新との衝突)を避けられます。 - イミュータブルに:React の state 更新と同じく、
oldを破壊せず新しい参照を返すこと。これで TanStack Query が変更を検知し、必要なコンポーネントだけ再レンダリングします。
7. Next.js App Router 連携:サーバープリフェッチ + ハイドレーション
App Router 環境では「Server Component でプリフェッチ → HydrationBoundary でクライアントに引き渡す」のが公式の定石です。初回表示は SSR で速く、以降はクライアントのキャッシュとして TanStack Query の全機能(再フェッチ・楽観更新)が使えます。
7-1. リクエストごとに分離する getQueryClient
サーバーではリクエストごとに新しい QueryClient を作り(ユーザー間のデータ混在を防ぐ)、ブラウザではシングルトンを再利用します。判定には公式が export する isServer を使います。
// app/get-query-client.ts
import { isServer, QueryClient } from "@tanstack/react-query";
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
// ハイドレーション直後の即時再フェッチを防ぐ。SSR では実質必須
queries: { staleTime: 60 * 1000 },
},
});
}
let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
if (isServer) return makeQueryClient(); // リクエストごとに新規生成
if (!browserQueryClient) browserQueryClient = makeQueryClient();
return browserQueryClient; // ブラウザでは使い回す
}
7-2. Server Component でプリフェッチ → dehydrate
queryOptions(第3章)をそのまま渡せるのがポイント。サーバーのプリフェッチとクライアントの useQuery で同じ定義を共有でき、キーと queryFn の二重管理が消えます。
// app/todos/page.tsx(Server Component)
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@/app/get-query-client";
import { todoListOptions } from "@/lib/todos/queries";
import { TodoList } from "./todo-list";
export default async function TodosPage() {
const queryClient = getQueryClient();
// 同じ queryOptions を使い回す(DRY)
await queryClient.prefetchQuery(todoListOptions({ type: "done" }));
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TodoList />
</HydrationBoundary>
);
}
// app/todos/todo-list.tsx(Client Component)
"use client";
import { useQuery } from "@tanstack/react-query";
import { todoListOptions } from "@/lib/todos/queries";
export function TodoList() {
// プリフェッチ済みのキャッシュから即座に描画される
const { data } = useQuery(todoListOptions({ type: "done" }));
return (
<ul>
{data?.map((t) => (
<li key={t.id}>{t.text}</li>
))}
</ul>
);
}
7-3. ストリーミング:await せずに流す
すべてのプリフェッチ完了を待たずに HTML を流したい場合は、shouldDehydrateQuery で pending 状態のクエリも dehydrate するよう設定し、prefetchQuery を await せずに呼びます。データが解決し次第クライアントへストリームされます。
// get-query-client.ts の defaultOptions に追加
import { defaultShouldDehydrateQuery } from "@tanstack/react-query";
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === "pending",
}
注意:プリフェッチした同じデータを Server Component と Client Component の両方でレンダリングしないこと(同期ズレの温床)。Server Component は「データ取得層」、表示はクライアントに寄せると破綻しません。
8. Suspense との統合:useSuspenseQuery
ローディング/エラーの分岐を <Suspense> と Error Boundary に外出ししたいなら useSuspenseQuery を使います。最大の利点は data が常に定義済み(Todo[]、undefined を含まない)になり、if (isPending) の分岐が消えてコードが直線的になることです。
"use client";
import { useSuspenseQuery } from "@tanstack/react-query";
import { todoListOptions } from "@/lib/todos/queries";
function TodoList({ filters }: { filters: TodoFilters }) {
// data は Todo[](undefined ではない)。isPending / error の分岐は不要
const { data } = useSuspenseQuery(todoListOptions(filters));
return (
<ul>
{data.map((t) => (
<li key={t.id}>{t.text}</li>
))}
</ul>
);
}
押さえどころ:
- ローディングは親の
<Suspense fallback={...}>、エラーは Error Boundary が担当します。 - デフォルトのエラー送出は「表示できる他データが無い時だけ throw」(
throwOnError: (e, q) => typeof q.state.data === "undefined")。古いキャッシュがあれば、それを見せたまま裏で再試行します。 placeholderDataは使えません。更新時のフォールバック表示を避けたい場合はstartTransitionで更新を包みます。- Error Boundary をリセットして再試行させるには
QueryErrorResetBoundary/useQueryErrorResetBoundaryを使います。
9. 本番運用のためのベストプラクティス&アンチパターン
| やるべきこと(Do) | 避けるべきこと(Don't) |
|---|---|
queryOptions + クエリキーファクトリで定義を一元化 | キーを文字列直書きして各所に散らす |
SSR/App Router では staleTime を明示(例 60s) | staleTime: 0 のままハイドレーション直後に二重フェッチ |
楽観更新は cancelQueries → 退避 → 更新 → onSettled同期 | onError 内で getQueryData し直してロールバックする |
サーバー状態は Query、UI状態は useState/軽量ストア | サーバーデータを useState にコピーして手動同期する |
エラー型は Error か Register で統一、境界で型ナローイング | catch (e: any) や as キャストで握りつぶす |
純粋な表示変更は setQueryData(updater) | 表示順の変更に useMutation を使う |
invalidateQueries は exact/predicate/refetchTypeで限定 | 親キーで一括無効化し、無関係なクエリまで再フェッチ |
可観測性の観点では、開発時に React Query Devtools を入れておくと、キャッシュ状態・stale 判定・再フェッチの発火が可視化され、上記の事故を未然に検知できます。
10. FAQ(よくある質問)
Q. staleTime と gcTime の違いは?
A. staleTime は「データを新鮮とみなす期間(=再フェッチを抑える)」、gcTime は「非アクティブなキャッシュをメモリ保持する期間」です。前者はネットワーク回数、後者はメモリ寿命を制御します。
Q. invalidateQueries と setQueryData と refetch の使い分けは?
A. サーバーと再同期したいなら invalidateQueries(stale 化+再フェッチ)、サーバーを呼ばずキャッシュを直接書き換えるなら setQueryData、特定クエリを今すぐ取り直すなら refetch です。
Q. TanStack Query は Redux / Zustand の代わりになりますか?
A. 「サーバー状態」については代替になります(むしろ最適)。一方、モーダル開閉やフォーム途中値などの「クライアント状態」は引き続き useState や軽量ストアが適任です。両者は競合せず役割分担します。
Q. Next.js の Server Components があるのに TanStack Query は必要? A. 初回表示は Server Components で十分なことも多いですが、クライアント側での再フェッチ・楽観更新・ポーリング・無限スクロール・キャッシュ共有が必要なら TanStack Query が有効です。プリフェッチ+ハイドレーションで両者は綺麗に両立します。
Q. 楽観的更新は UI ベースとキャッシュベース、どちらを選ぶ?
A. 更新結果を見せる箇所が1つなら UI ベース(variables)が最小コスト。複数箇所に同時反映するならキャッシュベース(onMutate + setQueryData)です。
Q. React 版に v6 はありますか? A. 2026年6月時点で React 版は v5 が最新です。「v6」は Svelte 用アダプタを指し、コアは v5 を共有しています。本記事のコードは v5 最新仕様に準拠しています。
まとめ:キャッシュは「アプリの現在の状態」である
TanStack Query v5 を使いこなす鍵は、キャッシュを「取得したデータの置き場」ではなく「アプリの正となる状態ストア」として能動的に設計することです。本記事の柱を振り返ると——
context.clientを使う新シグネチャで、Mutation のコールバックを簡潔・安全に書く。queryOptions+ クエリキーファクトリで、定義を一元化し型をすべてに流す。predicate/refetchTypeでネットワークを外科手術的に制御する。- 楽観的更新は2手法を使い分け、
cancelQueries→ 退避 → 更新 →onSettled同期で整合性を担保する。 - App Router ではプリフェッチ+ハイドレーション、必要に応じて Suspense で UI を直線化する。
これらは単なる小手先のテクニックではなく、可読性・型安全性・回復性・保守性を同時に引き上げる「設計の選択」です。正しく適用すれば、ユーザー体験(即応性)と開発体験(壊れにくさ)の両方が底上げされます。
実際のプロダクトでは、ここに「サーバー側のべき等性」「リトライ戦略」「監視・アラート」まで含めて初めて本番品質になります。こうした本番運用に耐えるフロントエンド設計やレビュー、既存コードの改善が必要な場合は、お気軽にご相談ください。 下記の事例では、業界の基幹業務を支える B2B SaaS を、型安全・回復性・保守性を重視して設計・実装した過程を紹介しています。