「TanStack Query 使い方を実務で迷わず書けるようになりたい」「サーバー状態(server state)とクライアント状態が別物だと聞くけれど、結局どこから手をつければ良いか分からない」「React Query キャッシュの仕組み・staleTimeとgcTimeの違い・楽観的更新の正しい書き方・useInfiniteQueryの実装パターンを、コピペで動く完成形コードで全部知りたい」――この記事はそんな現役Webエンジニア向けのTanStack Query (React Query) 完全実践ガイドです。TanStack Queryは旧称React Queryから改名したライブラリで、2026年現在React・Vue・Solid・Svelteの4フレームワークに対応するデファクト標準のサーバー状態管理ライブラリです。本記事ではv5系の最新APIを前提に、QueryClient設定・useQuery基本/型付け・queryKey設計・staleTime/gcTime・refetch戦略・enabled/select/placeholderData・useMutation・楽観的更新・invalidateQueries・useInfiniteQuery・prefetchQuery・useQueries・useSuspenseQuery・Devtools・MSWでのテスト・Next.js App Router連携(hydration)・Error Boundary・axios連携・TanStack Router連携までを、35本以上のTypeScriptコードブロック・表4つ・FAQ7問で徹底解説します。読み終えるころには、Redux Toolkit RTK Query や Jotai の atomWithQuery とは別物としての TanStack Query の強みを自分の言葉で説明でき、明日の実装に直接コピペできる雛形を手に入れているはずです。
- TanStack Queryとは何か〜サーバー状態管理という新概念
- インストールと最小構成〜QueryClientとProviderのマウント
- useQuery完全マスター〜基本・型付け・queryKey設計
- キャッシュ戦略〜staleTime・gcTime・refetchの三本柱
- 派生データ・初期データ・プレースホルダ
- useMutation〜更新系処理とinvalidate戦略
- useInfiniteQuery〜無限スクロール完全実装
- prefetch・useQueries・useSuspenseQuery
- エラーハンドリング・retry・Error Boundary
- axios連携・テスト・Next.js連携
- Devtools・TanStack Router・他状態管理との対比
- 2026年版TanStack Query学習・スキルアップロードマップ
- よくある質問(FAQ)
- まとめ〜サーバー状態管理の新時代へ
TanStack Queryとは何か〜サーバー状態管理という新概念
TanStack Query(旧React Query)は、Tanner Linsley氏が開発するサーバー状態管理ライブラリです。GitHubスター数は45,000を超え、週次ダウンロード数は600万件を突破。Reactの状態管理ライブラリの中でも「サーバー状態(server state)」専用という独自の立ち位置を確立しています。Redux Toolkit や Zustand、Jotai がクライアント状態(client state)を扱うのに対し、TanStack Query は「fetchで取得して画面に表示するためのリモートデータ」に特化しているのが最大の特徴です。
サーバー状態とクライアント状態は別物
多くのフロントエンドエンジニアが最初にハマるのが、「ReduxにAPIレスポンスを全部突っ込もうとして地獄を見る」という落とし穴です。サーバー状態は(1)自分のアプリで所有していない・(2)非同期APIで取得する必要がある・(3)他のユーザーによって裏で更新される・(4)古くなる(stale)概念があるという4つの特性を持ち、クライアント状態とは扱い方の本質が異なります。Redux Toolkit完全実践ガイドで扱ったRTK Queryも同じ問題意識から生まれたソリューションですが、TanStack Queryはより薄く・フレームワーク非依存で・キャッシュ戦略が柔軟に設計できます。
解決してくれる典型的な悩み
TanStack Queryを導入すると、自前で書くと数百行になる以下のロジックがuseQueryフック1本で吸収されます。「ローディング状態の管理」「エラー状態の管理」「キャッシュとその無効化」「タブ復帰時の再取得」「ネットワーク復帰時の再取得」「ポーリング」「楽観的更新」「ページネーション」「無限スクロール」「重複リクエストの排除(dedupe)」「依存リクエストの実行制御」――これらすべてが宣言的なAPIで書けます。
// TanStack Query以前: useStateとuseEffectで書く典型的なfetch
import { useEffect, useState } from "react";
type User = { id: number; name: string };
export function UserListLegacy() {
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
fetch("/api/users")
.then((r) => {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then((data: User[]) => {
if (!cancelled) setUsers(data);
})
.catch((e) => {
if (!cancelled) setError(e as Error);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, []);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
// TanStack Query版: 同じ機能が約7行で書ける(しかもキャッシュ・再取得・dedupe付き)
import { useQuery } from "@tanstack/react-query";
type User = { id: number; name: string };
export function UserList() {
const { data, isPending, error } = useQuery<User[]>({
queryKey: ["users"],
queryFn: async () => {
const r = await fetch("/api/users");
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
},
});
if (isPending) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return <ul>{data!.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
インストールと最小構成〜QueryClientとProviderのマウント
パッケージのインストール
TanStack Queryは本体の@tanstack/react-queryに加え、開発時に必須のDevtools@tanstack/react-query-devtools、ESLintルールの@tanstack/eslint-plugin-queryをセットで入れるのが2026年標準です。Vue/Solid/Svelteは別パッケージですが、本記事はReact版に絞ります。
# npm
npm install @tanstack/react-query
npm install -D @tanstack/react-query-devtools @tanstack/eslint-plugin-query
# pnpm
pnpm add @tanstack/react-query
pnpm add -D @tanstack/react-query-devtools @tanstack/eslint-plugin-query
# yarn
yarn add @tanstack/react-query
yarn add -D @tanstack/react-query-devtools @tanstack/eslint-plugin-query
# Next.js連携でSSRをやる場合
npm install @tanstack/react-query @tanstack/react-query-next-experimental
QueryClientの生成とデフォルトオプション
QueryClientはTanStack Queryの中心となるキャッシュストアで、アプリ全体で1インスタンスのみを生成して使い回すのが鉄則です。コンポーネント関数の内側でnew QueryClient()すると、再レンダリングごとに別インスタンスが生成されキャッシュが吹き飛びます。useStateまたはuseRefで生成時を1度に限定するのが定石です。
// NG: コンポーネントの内側でnewするとre-render毎にキャッシュが消える
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
export function AppNG() {
const queryClient = new QueryClient(); // ❌ 毎回新しいキャッシュ
return (
<QueryClientProvider client={queryClient}>
<Routes />
</QueryClientProvider>
);
}
// OK: useStateの初期化関数で1回だけ生成する正解パターン
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
export function App() {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // 60秒間は再fetchしない
gcTime: 5 * 60_000, // 5分使われなければキャッシュ破棄
retry: 1, // 失敗時1回だけ自動リトライ
refetchOnWindowFocus: true, // タブ復帰時に再fetch
},
mutations: {
retry: 0,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
<Routes />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Providerをアプリのルートに配置する
Reduxと同様、QueryClientProviderでアプリ全体をラップする必要があります。useContext完全ガイドで扱ったContext APIの仕組みを使っているため、Providerより内側のコンポーネントだけがuseQueryを呼び出せます。Next.js App Routerでは別パターンが必要なので後述します。
// Vite + React 18系の典型的なエントリポイント
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(<App />);
useQuery完全マスター〜基本・型付け・queryKey設計
useQueryの最小コード
useQueryは最低限queryKeyとqueryFnの2つだけ指定すれば動きます。queryKeyはキャッシュエントリの識別子(必ず配列)、queryFnは実際にデータを取得する非同期関数です。queryFnはPromiseを返し、エラー時はthrowするのが鉄則です。fetchはHTTP 4xx/5xxでrejectされないので、自分でthrowする必要があります。
// useQueryの最小コード(エラーthrowを忘れない)
import { useQuery } from "@tanstack/react-query";
type Todo = { id: number; title: string; completed: boolean };
const fetchTodos = async (): Promise<Todo[]> => {
const res = await fetch("/api/todos");
if (!res.ok) {
// fetchはHTTPエラーで自動rejectしないので手動throw
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return res.json();
};
export function TodoList() {
const { data, isPending, isError, error } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});
if (isPending) return <p>Loading...</p>;
if (isError) return <p>Error: {error.message}</p>;
return (
<ul>
{data.map((t) => (
<li key={t.id}>{t.title}</li>
))}
</ul>
);
}
TypeScript型を厳密に効かせる書き方
TanStack Queryのv5系は型推論が非常に強いため、queryFnの戻り値型さえ正しく書けば、dataの型は自動で推論されます。明示的にジェネリクスを指定する必要はほとんどありませんが、エラー型(TError)をカスタムしたい場合だけ書きます。TypeScript完全実践ガイドで扱ったジェネリクスの知識がここで活きてきます。
// パターン1: queryFnの戻り型から自動推論(推奨)
import { useQuery } from "@tanstack/react-query";
type User = { id: number; name: string; email: string };
const fetchUser = async (id: number): Promise<User> => {
const r = await fetch(`/api/users/${id}`);
if (!r.ok) throw new Error("ユーザー取得に失敗");
return r.json();
};
export function UserProfile({ id }: { id: number }) {
// data は User | undefined と自動推論される
const { data, isPending } = useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
});
if (isPending) return <p>Loading...</p>;
return <p>{data!.name} ({data!.email})</p>;
}
// パターン2: カスタムエラー型を効かせる
import { useQuery } from "@tanstack/react-query";
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
const fetchTodos = async () => {
const r = await fetch("/api/todos");
if (!r.ok) throw new ApiError(r.status, await r.text());
return r.json() as Promise<Todo[]>;
};
type Todo = { id: number; title: string };
export function TodosTyped() {
const { data, error } = useQuery<Todo[], ApiError>({
queryKey: ["todos"],
queryFn: fetchTodos,
});
// error は ApiError | null で .status にアクセスできる
if (error) return <p>[{error.status}] {error.message}</p>;
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
queryKeyの設計原則〜階層配列が鉄則
queryKey設計はTanStack Queryで一番ハマるポイントです。関連性のある順に階層化された配列を作るのが原則で、例えばユーザー一覧は["users", "list", { filters }]、特定ユーザーは["users", "detail", userId]のように設計します。これはinvalidateQueriesでの一括無効化のしやすさに直結します。
// アンチパターン: フラットでバラバラなqueryKey
useQuery({ queryKey: ["users"], queryFn: ... });
useQuery({ queryKey: ["user-by-id-" + id], queryFn: ... }); // ❌ 文字列連結NG
useQuery({ queryKey: ["users-filtered-" + JSON.stringify(filter)], ... }); // ❌
// 良いパターン: 階層化された配列
useQuery({ queryKey: ["users", "list"], queryFn: ... });
useQuery({ queryKey: ["users", "list", { filter }], queryFn: ... });
useQuery({ queryKey: ["users", "detail", id], queryFn: ... });
useQuery({ queryKey: ["posts", "detail", postId, "comments"], queryFn: ... });
Query Key Factoryパターンで安全に管理
queryKeyを文字列で散らかすとtypo事故が多発します。Query Key Factoryと呼ばれる集約パターンで、エンティティ単位にkey生成関数をまとめるのがチーム開発でのデファクトです。invalidateする側もこのオブジェクトを参照するだけになり、リファクタリング耐性が劇的に上がります。
// query-keys.ts 全queryKeyを一元管理するファクトリ
export const userKeys = {
all: ["users"] as const,
lists: () => [...userKeys.all, "list"] as const,
list: (filters: { search?: string; role?: string }) =>
[...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, "detail"] as const,
detail: (id: number) => [...userKeys.details(), id] as const,
};
export const todoKeys = {
all: ["todos"] as const,
lists: () => [...todoKeys.all, "list"] as const,
detail: (id: number) => [...todoKeys.all, "detail", id] as const,
};
// 利用側
import { userKeys } from "./query-keys";
useQuery({
queryKey: userKeys.detail(123),
queryFn: () => fetchUser(123),
});
// invalidate時もこのファクトリを使う
queryClient.invalidateQueries({ queryKey: userKeys.all }); // users全体
queryClient.invalidateQueries({ queryKey: userKeys.lists() }); // 一覧だけ
queryClient.invalidateQueries({ queryKey: userKeys.detail(123) }); // 個別だけ
キャッシュ戦略〜staleTime・gcTime・refetchの三本柱
staleTimeとgcTimeは別物
TanStack Queryのキャッシュ理解でもっとも重要なのがstaleTimeとgcTimeの区別です。staleTimeは「データが新鮮(fresh)とみなされる期間」、gcTime(旧cacheTime)は「キャッシュがメモリに保持される期間」を表します。両者は完全に別の概念で、混同すると意図しないfetchが頻発したり、必要なキャッシュが消えたりします。
| 項目 | staleTime | gcTime(旧cacheTime) |
|---|---|---|
| 意味 | データが新鮮とみなされる期間 | 非アクティブキャッシュの保持期間 |
| デフォルト | 0(常にstale) | 5分 |
| 超えると起きること | マウント/フォーカス時に再fetch | メモリから完全削除 |
| 典型的な設定値 | 30秒〜10分(データ更新頻度に応じて) | 5〜30分(画面遷移パターンに応じて) |
| 影響範囲 | 再取得タイミング | メモリ使用量・即時復帰の可否 |
staleTimeの実用的な設定パターン
staleTime: 0(デフォルト)は「画面を開く度にfetch」を意味し、リアルタイム性が要求されるダッシュボードでは正しいですが、ユーザー名や設定など滅多に変わらないデータでは無駄が大きすぎます。データの種類ごとに使い分けます。
// データ種別ごとのstaleTime設計例
import { useQuery } from "@tanstack/react-query";
// 頻繁に変わる: チャットの未読数
useQuery({
queryKey: ["notifications", "unread"],
queryFn: fetchUnread,
staleTime: 0, // 常に最新を取りに行く
refetchInterval: 30_000, // 30秒ごとポーリング
});
// たまに変わる: ブログ記事一覧
useQuery({
queryKey: ["posts", "list"],
queryFn: fetchPosts,
staleTime: 5 * 60_000, // 5分はfetchしない
});
// めったに変わらない: マスタ系(都道府県・カテゴリ)
useQuery({
queryKey: ["masters", "prefectures"],
queryFn: fetchPrefectures,
staleTime: Infinity, // セッション中は再fetch不要
gcTime: Infinity,
});
refetchOnWindowFocus / refetchInterval
TanStack Queryのデフォルトは「ブラウザタブが復帰したら再fetch」(refetchOnWindowFocus: true)です。これが嬉しい場面と煩わしい場面があるので、画面単位やQueryClient全体で調整します。ポーリングはrefetchIntervalに数値(ms)を渡すだけで実装できます。
// refetch挙動の細かい制御
import { useQuery } from "@tanstack/react-query";
useQuery({
queryKey: ["stock-price", symbol],
queryFn: () => fetchStockPrice(symbol),
refetchOnWindowFocus: true, // タブ復帰時に再fetch
refetchOnReconnect: true, // ネット復帰時に再fetch
refetchOnMount: "always", // マウント毎に必ず再fetch
refetchInterval: 10_000, // 10秒ごとポーリング
refetchIntervalInBackground: false, // バックグラウンドタブではポーリングしない
});
// 動的に間隔を変えるパターン: 取引時間中だけ短く
useQuery({
queryKey: ["stock-price", symbol],
queryFn: () => fetchStockPrice(symbol),
refetchInterval: (query) => {
const hour = new Date().getHours();
return hour >= 9 && hour < 15 ? 5_000 : 60_000;
},
});
enabledで依存リクエストを制御する
useQueryは関数コンポーネントのトップレベルでしか呼べないため、「ユーザー情報が取れてからその子情報を取る」のような依存パターンはenabledで表現します。enabled: falseのときはqueryFnが実行されず、isPending: trueのままになります。
// 依存リクエスト: userIdが取れてからuserDetailを取る
import { useQuery } from "@tanstack/react-query";
function UserProfileWithPosts() {
const { data: user } = useQuery({
queryKey: ["currentUser"],
queryFn: fetchCurrentUser,
});
// userIdが揃ったらpostsを取得(undefinedの間は実行されない)
const { data: posts } = useQuery({
queryKey: ["posts", "byUser", user?.id],
queryFn: () => fetchPostsByUser(user!.id),
enabled: !!user?.id, // userIdが取れるまで実行しない
});
return (
<div>
<h2>{user?.name}</h2>
<ul>{posts?.map((p) => <li key={p.id}>{p.title}</li>)}</ul>
</div>
);
}
派生データ・初期データ・プレースホルダ
selectで派生データを作る
selectオプションを渡すと、取得した生データから派生データを抽出できます。Jotai完全実践ガイドの派生atomと同じ発想で、コンポーネント側でdata.map(...)するよりキャッシュは生データのまま・コンポーネントは必要な形だけ受け取るのがクリーンです。
// select: 生データから完了済みTodoの件数だけ取り出す
import { useQuery } from "@tanstack/react-query";
type Todo = { id: number; title: string; completed: boolean };
function CompletedCount() {
const { data: completedCount } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
// dataはTodo[]だがコンポーネント側にはnumberだけ届く
select: (todos: Todo[]) => todos.filter((t) => t.completed).length,
});
return <p>完了済み: {completedCount ?? 0}件</p>;
}
// 別コンポーネントは未完了だけを購読(同じキャッシュを共有)
function PendingList() {
const { data: pendingTodos } = useQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
select: (todos: Todo[]) => todos.filter((t) => !t.completed),
});
return <ul>{pendingTodos?.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;
}
placeholderDataとinitialDataの違い
初回ローディング中に何を表示するかを指定する仕組みが2種類あります。placeholderDataは「表示用の仮データ」(キャッシュには入らない、isPlaceholderData: trueで識別可能)、initialDataは「キャッシュに本物として書き込む初期値」(staleTimeの起点になる)。両者の使い分けは重要です。
// placeholderData: 前ページのデータを表示しながら新しいページを取りに行く
import { useQuery, keepPreviousData } from "@tanstack/react-query";
function PostsPaginated({ page }: { page: number }) {
const { data, isPlaceholderData } = useQuery({
queryKey: ["posts", "list", page],
queryFn: () => fetchPostsPage(page),
placeholderData: keepPreviousData, // 前ページのキャッシュを仮表示
});
return (
<div style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
{data?.map((p) => (
<article key={p.id}>{p.title}</article>
))}
</div>
);
}
// initialData: SSRから渡された値をキャッシュに直接書き込む
import { useQuery } from "@tanstack/react-query";
function UserProfilePreloaded({
ssrUser,
id,
}: {
ssrUser: User;
id: number;
}) {
const { data } = useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
initialData: ssrUser, // SSRで取った値をキャッシュに入れる
initialDataUpdatedAt: Date.now() - 30_000, // 30秒前に取った扱い
staleTime: 60_000, // 残り30秒はfreshとみなされる
});
return <h2>{data.name}</h2>;
}
useMutation〜更新系処理とinvalidate戦略
useMutationの基本
POST/PUT/DELETEなどの「副作用を伴うリクエスト」はuseMutationで扱います。useQueryと違い自動実行されず、mutate()を呼んだ時だけ走るのが大きな違いです。成功時に関連クエリをinvalidateして再取得を促すのが鉄板パターンです。
// useMutation基本: Todoを追加してリストをinvalidate
import { useMutation, useQueryClient } from "@tanstack/react-query";
type NewTodo = { title: string };
type Todo = { id: number; title: string; completed: boolean };
const createTodo = async (input: NewTodo): Promise<Todo> => {
const r = await fetch("/api/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!r.ok) throw new Error("作成に失敗");
return r.json();
};
export function TodoForm() {
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
// todos一覧を再fetchして最新化
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate({ title });
setTitle("");
}}
>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button disabled={mutation.isPending}>
{mutation.isPending ? "送信中..." : "追加"}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
);
}
mutate vs mutateAsyncの使い分け
mutationにはmutate(fire-and-forget、戻り値なし)とmutateAsync(Promiseを返す)の2種類があります。連続処理や条件分岐がある場合のみmutateAsyncを使い、普段はmutateで十分です。mutateAsyncはonError等を自分でtry-catchする必要が出る点に注意してください。
// mutate: fire-and-forget(onSuccess/onErrorで完結)
mutation.mutate({ title: "牛乳を買う" });
// mutateAsync: await可能(連続処理に便利だがtry-catch必須)
async function handleSubmit() {
try {
const created = await mutation.mutateAsync({ title: "牛乳を買う" });
await mutation.mutateAsync({ title: "卵を買う" });
navigate(`/todos/${created.id}`);
} catch (e) {
// onErrorも呼ばれるがawait側でも捕捉する必要あり
console.error(e);
}
}
onMutateで楽観的更新(optimistic update)を実装する
SNSの「いいね」ボタンのように、サーバーレスポンスを待たずに即座にUIを更新する手法を楽観的更新(optimistic update)と呼びます。TanStack QueryではonMutateで先回り更新し、エラー時にonErrorでロールバックするのが定石パターンです。
// 楽観的更新の完成形: いいねを即座にUIに反映しエラー時はロールバック
import { useMutation, useQueryClient } from "@tanstack/react-query";
type Post = { id: number; title: string; likes: number; liked: boolean };
const toggleLike = async (postId: number): Promise<Post> => {
const r = await fetch(`/api/posts/${postId}/like`, { method: "POST" });
if (!r.ok) throw new Error("いいねに失敗");
return r.json();
};
export function LikeButton({ post }: { post: Post }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: toggleLike,
// (1) Mutation実行前: 楽観的にキャッシュを書き換える
onMutate: async (postId) => {
// 進行中のfetchを停止してレース回避
await queryClient.cancelQueries({ queryKey: ["posts", "detail", postId] });
const previous = queryClient.getQueryData<Post>([
"posts",
"detail",
postId,
]);
// 楽観的にキャッシュを書き換え
if (previous) {
queryClient.setQueryData<Post>(["posts", "detail", postId], {
...previous,
liked: !previous.liked,
likes: previous.liked ? previous.likes - 1 : previous.likes + 1,
});
}
// ロールバック用のスナップショットを返す(contextに入る)
return { previous };
},
// (2) エラー時: スナップショットでロールバック
onError: (_err, postId, context) => {
if (context?.previous) {
queryClient.setQueryData(
["posts", "detail", postId],
context.previous
);
}
},
// (3) 成功/失敗どちらでも最後にサーバーと同期
onSettled: (_data, _err, postId) => {
queryClient.invalidateQueries({
queryKey: ["posts", "detail", postId],
});
},
});
return (
<button onClick={() => mutation.mutate(post.id)}>
{post.liked ? "♥" : "♡"} {post.likes}
</button>
);
}
invalidateQueriesとsetQueryDataの使い分け
キャッシュ更新には2つの戦略があります。invalidateQueriesは「無効化マークを付けて次回再fetchを促す」、setQueryDataは「サーバーレスポンスからキャッシュを直接書き換える」です。データ量が小さく即座にUIへ反映したいときはsetQueryData、整合性最優先ならinvalidateQueriesが原則です。
| シチュエーション | 推奨 | 理由 |
|---|---|---|
| POST作成後、最新リストを取り直したい | invalidateQueries | サーバー側計算結果(順序・ページネーション)を信頼 |
| PUT更新でレスポンスに完全な新データが返る | setQueryData | 追加fetch不要・即座にUIへ反映 |
| 楽観的更新の先回り反映 | setQueryData | レスポンスを待たず即時UI更新 |
| DELETE後、関連エンティティも巻き込む | invalidateQueries(複数) | 影響範囲が広く一括無効化が安全 |
| リアルタイム配信(WebSocket)から受信 | setQueryData | サーバー再fetch不要・帯域節約 |
// setQueryData: サーバーレスポンスでキャッシュを直接書き換える
const updateTodo = async (todo: Todo): Promise<Todo> => {
const r = await fetch(`/api/todos/${todo.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(todo),
});
if (!r.ok) throw new Error("更新失敗");
return r.json();
};
const mutation = useMutation({
mutationFn: updateTodo,
onSuccess: (updated) => {
// 個別キャッシュを直接書き換え
queryClient.setQueryData(["todos", "detail", updated.id], updated);
// 一覧側のキャッシュも書き換える(配列内の該当項目を差し替え)
queryClient.setQueryData<Todo[]>(["todos", "list"], (old) =>
old?.map((t) => (t.id === updated.id ? updated : t))
);
},
});
useInfiniteQuery〜無限スクロール完全実装
useInfiniteQueryのコア概念
無限スクロールやカーソルベースのページネーションにはuseInfiniteQueryを使います。通常のuseQueryと違い「複数ページのデータを連結して保持」するため、レスポンスはdata.pages: T[][]とdata.pageParams: unknown[]の二段構えになります。getNextPageParamで次のページのカーソルを返す関数を必ず実装します。
// useInfiniteQuery基本: カーソルベースのページネーション
import { useInfiniteQuery } from "@tanstack/react-query";
type Page<T> = { items: T[]; nextCursor: string | null };
type Post = { id: number; title: string };
const fetchPostsPage = async (
cursor: string | null
): Promise<Page<Post>> => {
const params = new URLSearchParams();
if (cursor) params.set("cursor", cursor);
const r = await fetch(`/api/posts?${params}`);
if (!r.ok) throw new Error("読み込み失敗");
return r.json();
};
export function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isPending,
} = useInfiniteQuery({
queryKey: ["posts", "infinite"],
queryFn: ({ pageParam }) => fetchPostsPage(pageParam),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
if (isPending) return <p>Loading...</p>;
return (
<div>
{data.pages.map((page, i) => (
<React.Fragment key={i}>
{page.items.map((p) => (
<article key={p.id}>{p.title}</article>
))}
</React.Fragment>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? "Loading..." : hasNextPage ? "もっと読む" : "終了"}
</button>
</div>
);
}
IntersectionObserverで自動ロード
ボタンクリックではなくスクロールで自動的に次ページを読み込むには、カスタムフック作り方完全ガイドで扱った発想で、IntersectionObserverをラップしたuseIntersectionと組み合わせます。
// useIntersection: 末尾sentinelが画面に入ったらfetchNextPage()を呼ぶ
import { useEffect, useRef } from "react";
function useInfiniteScroll(onIntersect: () => void, enabled: boolean) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!enabled || !ref.current) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) onIntersect();
},
{ rootMargin: "200px" }
);
observer.observe(ref.current);
return () => observer.disconnect();
}, [onIntersect, enabled]);
return ref;
}
// 利用例
export function AutoLoadingPostList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({ /* 略 */ });
const sentinelRef = useInfiniteScroll(
() => fetchNextPage(),
!!hasNextPage && !isFetchingNextPage
);
return (
<div>
{/* 投稿描画は省略 */}
<div ref={sentinelRef} style={{ height: 1 }} />
{isFetchingNextPage && <p>追加読み込み中...</p>}
</div>
);
}
prefetch・useQueries・useSuspenseQuery
prefetchQueryで先回り取得
「リンクにhoverしたら遷移先のデータを先取り」「ログイン直後にダッシュボードのデータを並列取得」など、ユーザーの行動を先読みしたい場合はqueryClient.prefetchQueryを使います。Reactイベントハンドラから呼ぶだけで、キャッシュに入ったデータが次のuseQueryで即座に使われます。
// hover prefetch: リンクhoverで遷移先データを先取り
import { useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";
function PostLink({ post }: { post: { id: number; title: string } }) {
const queryClient = useQueryClient();
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ["posts", "detail", post.id],
queryFn: () => fetchPostDetail(post.id),
staleTime: 30_000,
});
};
return (
<Link to={`/posts/${post.id}`} onMouseEnter={prefetch} onFocus={prefetch}>
{post.title}
</Link>
);
}
// usePrefetchQuery (v5新): コンポーネントマウント時に並行fetch
import { usePrefetchQuery } from "@tanstack/react-query";
function Dashboard() {
// 表示中ではないが先回りでキャッシュに入れる
usePrefetchQuery({
queryKey: ["reports", "monthly"],
queryFn: fetchMonthlyReport,
});
usePrefetchQuery({
queryKey: ["notifications", "list"],
queryFn: fetchNotifications,
});
// 実際に表示するメインデータ
const { data } = useQuery({
queryKey: ["dashboard", "summary"],
queryFn: fetchDashboardSummary,
});
return <DashboardView data={data} />;
}
useQueriesで並列クエリ
「ユーザーID配列からそれぞれの詳細を並列取得」のように、配列に対してuseQueryを動的に展開したい場合はuseQueriesを使います。Hooksのルール上useQueryをループで呼ぶことはできないため、useQueriesが唯一の正しい解です。
// useQueries: 動的な数のqueryを並列実行
import { useQueries } from "@tanstack/react-query";
function UsersByIds({ ids }: { ids: number[] }) {
const results = useQueries({
queries: ids.map((id) => ({
queryKey: ["users", "detail", id],
queryFn: () => fetchUser(id),
staleTime: 60_000,
})),
});
const isPending = results.some((r) => r.isPending);
const isError = results.some((r) => r.isError);
if (isPending) return <p>Loading...</p>;
if (isError) return <p>Error</p>;
return (
<ul>
{results.map((r, i) => (
<li key={ids[i]}>{r.data?.name}</li>
))}
</ul>
);
}
useSuspenseQueryでSuspense統合
v5から導入されたuseSuspenseQueryは、isPendingを返さず、データが揃うまでReactのSuspenseに委ねる新しいフックです。コンポーネント内のローディング分岐が一切不要になり、コードが劇的にシンプルになります。React Server Componentsとの相性も抜群です。
// useSuspenseQuery: isPending分岐が消える(代わりに親がSuspense必須)
import { useSuspenseQuery } from "@tanstack/react-query";
import { Suspense } from "react";
function UserProfileSuspense({ id }: { id: number }) {
// dataは常にUser型(undefinedにならない)
const { data: user } = useSuspenseQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
});
return <h2>{user.name}</h2>;
}
// 親側でSuspenseを置く
export function Page({ id }: { id: number }) {
return (
<Suspense fallback={<p>Loading user...</p>}>
<UserProfileSuspense id={id} />
</Suspense>
);
}
エラーハンドリング・retry・Error Boundary
retry戦略のカスタマイズ
デフォルトのretry: 3はネットワーク不安定環境では便利だが、4xxエラー(認証切れ等)には無意味です。HTTPステータスを見て選択的にretryさせるのが実務上のベストプラクティスです。
// retry: 4xx系はretryしない・5xxは最大3回・指数バックオフ
import { QueryClient } from "@tanstack/react-query";
class HttpError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
if (error instanceof HttpError) {
// 4xx系はretryしない
if (error.status >= 400 && error.status < 500) return false;
}
return failureCount < 3;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
},
},
});
Error Boundaryと組み合わせる
各コンポーネントでif (isError) return ...を書くのが煩わしい場合、throwOnError(旧useErrorBoundary)でエラーをReactのError Boundaryに投げ上げる方式が便利です。
// Error Boundary連携: コンポーネント内でエラー分岐が不要に
import { useQuery } from "@tanstack/react-query";
import { ErrorBoundary } from "react-error-boundary";
function UserProfileThrow({ id }: { id: number }) {
const { data } = useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
throwOnError: true, // エラー時はError Boundaryに丸投げ
});
return <h2>{data!.name}</h2>;
}
export function Page() {
return (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>エラー: {(error as Error).message}</p>
<button onClick={resetErrorBoundary}>再試行</button>
</div>
)}
>
<UserProfileThrow id={1} />
</ErrorBoundary>
);
}
axios連携・テスト・Next.js連携
axiosをqueryFnに組み込む
fetchではなくaxiosを使うチームも多いでしょう。共通インスタンスにinterceptorで認証ヘッダを付け、queryFnからはそれを呼ぶだけにすると、テストもしやすくなります。
// api-client.ts axios共通インスタンス
import axios from "axios";
export const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10_000,
});
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
apiClient.interceptors.response.use(
(res) => res,
(error) => {
if (error.response?.status === 401) {
// 認証切れの統一処理
window.location.href = "/login";
}
return Promise.reject(error);
}
);
// queryFnからaxiosを呼ぶ
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "./api-client";
type User = { id: number; name: string };
export function useUser(id: number) {
return useQuery({
queryKey: ["users", "detail", id],
queryFn: async () => {
const { data } = await apiClient.get<User>(`/users/${id}`);
return data;
},
});
}
MSWでのテスト
TanStack Queryを使うコンポーネントのテストでは、MSW(Mock Service Worker)でAPIをモックし、コンポーネントと一緒にQueryClientProviderでラップするのが定石です。各テストごとに新しいQueryClientを作るのを忘れずに。
// test-utils.tsx 共通レンダラ
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
export function renderWithClient(ui: React.ReactElement) {
// テストごとにretry無効化・staleTime無限の専用clientを作る
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
},
});
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
);
}
// UserProfile.test.tsx MSWでAPIモック+useQueryコンポーネントをテスト
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { screen } from "@testing-library/react";
import { renderWithClient } from "./test-utils";
import { UserProfile } from "./UserProfile";
const server = setupServer(
http.get("/api/users/1", () =>
HttpResponse.json({ id: 1, name: "山田太郎" })
)
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test("ユーザー名が表示される", async () => {
renderWithClient(<UserProfile id={1} />);
expect(await screen.findByText("山田太郎")).toBeInTheDocument();
});
Next.js App Routerでのhydration連携
Next.js App Routerでは、Server Componentでprefetchしたキャッシュをdehydrate→hydrateすることで、SSRで取ったデータをそのままクライアントのキャッシュにも入れる連携が可能です。
// app/providers.tsx Client側Provider
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() => new QueryClient({ defaultOptions: { queries: { staleTime: 60_000 } } })
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
// app/posts/page.tsx Server Componentでprefetch→dehydrate
import { QueryClient, dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { PostList } from "./PostList";
export default async function PostsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["posts", "list"],
queryFn: async () => {
const r = await fetch("https://api.example.com/posts");
return r.json();
},
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PostList />
</HydrationBoundary>
);
}
Devtools・TanStack Router・他状態管理との対比
ReactQueryDevtools活用
開発時の必須ツールがDevtoolsです。画面右下のアイコンから現在のクエリ一覧・キャッシュ内容・status(fresh/stale/inactive)・最終更新時刻が一目で見えます。本番ビルドでは自動的にtree-shakeされるので入れっぱなしでOK。
// 本番では自動的に除外されるDevtools
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
<QueryClientProvider client={queryClient}>
{/* ...children... */}
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools
initialIsOpen={false}
buttonPosition="bottom-right"
/>
)}
</QueryClientProvider>
TanStack Routerと組み合わせる(loader)
同じ作者(Tanner Linsley氏)が手掛けるTanStack Routerと組み合わせると、ルートのloaderからprefetchQueryを呼んでデータを先回り取得しつつ、コンポーネントでuseQueryが即座にキャッシュヒットする「ナビゲーション込みの最強体験」を実現できます。
// TanStack Router loaderからprefetch
import { createFileRoute } from "@tanstack/react-router";
import { queryClient } from "./queryClient";
export const Route = createFileRoute("/posts/$postId")({
loader: ({ params }) =>
queryClient.ensureQueryData({
queryKey: ["posts", "detail", params.postId],
queryFn: () => fetchPostDetail(params.postId),
}),
component: PostDetailPage,
});
function PostDetailPage() {
const { postId } = Route.useParams();
// loaderで取得済みなので即座にキャッシュヒット
const { data } = useQuery({
queryKey: ["posts", "detail", postId],
queryFn: () => fetchPostDetail(postId),
});
return <article>{data?.title}</article>;
}
他の状態管理ライブラリとの責務分担
「ZustandやJotaiを既に使っているけどTanStack Queryも入れるべき?」――答えはYESです。両者は競合せず役割が違います。下表のように「クライアント状態はZustand/Jotai、サーバー状態はTanStack Query」と分けるのが2026年のベストプラクティスです。
| 状態の種類 | 例 | 推奨ライブラリ |
|---|---|---|
| UI状態 | モーダル開閉・サイドバー折り畳み | useState / Zustand / Jotai |
| フォーム入力中の値 | テキスト・選択値・バリデーション | React Hook Form / useState |
| テーマ・i18n言語 | ダーク/ライト・ロケール | Context / Zustand / Jotai |
| サーバーデータ | ユーザー一覧・記事詳細・通知 | TanStack Query |
| サーバーデータの楽観的更新 | いいね・お気に入り・カート | TanStack Query |
| ページネーション | 無限スクロール・ページ番号 | TanStack Query (useInfiniteQuery) |
RTK QueryやatomWithQueryとの違い
Redux Toolkit完全実践ガイドのRTK Queryも同じくサーバー状態管理ですが、Reduxストアと結合している点が違いです。RTK QueryはReduxを既に使っているプロジェクト向け、TanStack QueryはRedux/Zustand/Jotaiなど何と組んでも独立して動くのが強み。Jotai完全実践ガイドのatomWithQueryはJotai統合用の薄いラッパーで、内部実装はTanStack Queryを使っています。React状態管理ライブラリ完全比較もあわせて参照してください。
2026年版TanStack Query学習・スキルアップロードマップ
ここまで読んで「TanStack Queryのコードは書ける気がしてきた」と感じた人は、次は商用Webアプリケーション全体を設計・運用できるエンジニアとして案件単価を引き上げるフェーズです。Reactエコシステム(Next.js / TanStack Router / TanStack Query / Zustand / Jotai)を仕事で書ける人材は2026年も明確に不足しており、学習投資のリターンが大きい領域です。実務未経験から最短で現場投入を目指すなら、メンター付きスクールが圧倒的に効率的です。DMM WEBCAMPは専属メンターによるコードレビューでReact+TypeScript案件レベルの実装力が身につくこと、テックアカデミーは週2回のマンツーマンメンタリングでTanStack Query等の最新ライブラリも個別に質問できること、侍エンジニアはオーダーメイドカリキュラムで「現職を辞めずに副業案件を取りに行く」プランも組めることが強みです。すでに現役エンジニアでフリーランス転向を視野に入れている人は、Reactフロントエンドの高単価案件が豊富なレバテックフリーランスでの週1面談で市場価値を測ってみることをおすすめします。「TanStack Queryでサーバー状態管理を書けます」「Next.js App RouterのHydrationまで実装経験ありです」と語れるレベルになれば、月単価80万〜120万円のReact案件は十分射程です。
よくある質問(FAQ)
Q1. TanStack QueryとReact Queryは何が違うのですか?
同じライブラリです。v4からReact Query→TanStack Queryに改名されました。React以外のフレームワーク(Vue/Solid/Svelte)も同じコアエンジンでサポートするようになったための名称統一です。npmパッケージ名もreact-queryから@tanstack/react-queryに変わっているので、v3以前のコードをアップグレードする場合はimportパスの変更が必要です。
Q2. staleTimeを長くするとデータが古くなって心配です。
staleTimeは「自動再fetchしない期間」であって「強制的にキャッシュを使い続ける」設定ではありません。queryClient.invalidateQueriesやrefetch()を呼べばいつでも手動で取り直せます。Mutationでデータを更新した直後はinvalidateを呼ぶ、というパターンを守れば、staleTime: 5分でも整合性は保てます。
Q3. ReduxやZustandを既に使っています。TanStack Queryに置き換えるべきですか?
置き換える必要はなく、共存させるのが正解です。クライアント状態(モーダル開閉やテーマ設定)はZustand/Jotai、サーバー状態(APIから取るデータ)はTanStack Queryと役割分担させてください。Reduxに無理矢理APIレスポンスを入れていた部分だけTanStack Queryへ移行すると、ボイラープレートが激減します。
Q4. 楽観的更新で失敗したらどうなりますか?
onMutateでスナップショットをcontextとして返しておき、onErrorでそのスナップショットを使ってqueryClient.setQueryDataでロールバックします。本記事の「LikeButton」サンプルがその完成形です。onSettledで最後にinvalidateQueriesを呼んでおくと、ロールバック後にサーバー側の最新状態と確実に同期できます。
Q5. queryFnの中で何をthrowすればいいですか?
Errorインスタンス(またはそのサブクラス)をthrowしてください。fetchはHTTP 4xx/5xxでrejectされないので、if (!res.ok) throw new Error(...)を自分で書く必要があります。HTTPステータスをretryロジックで使いたい場合は、HttpErrorのようなカスタムクラスを定義してthrowするとretry関数でerror.statusを判定できます。
Q6. SSR(Next.js)でhydrationエラーが出ます。
v5系ではHydrationBoundary(以前はHydrate)でラップする必要があります。また、Server ComponentでQueryClientを毎リクエスト新規生成し、Client Component側のQueryClientはuseStateで1回だけ生成すること、prefetchQueryで取ったキャッシュをdehydrateしてHydrationBoundary経由で渡すことを徹底すれば、サーバーとクライアントの状態が一致します。本記事の「Next.js App Router」セクションを参照してください。
Q7. useInfiniteQueryでスクロール位置がリセットされてしまいます。
原因はqueryKeyが意図せず変わって全データが再取得されているケースが大半です。検索キーワード等で再取得が必要な場合はplaceholderData: keepPreviousDataを渡し、前回のpagesを表示したまま新しいデータをfetchする挙動にすると、画面のがたつきもスクロール位置リセットも防げます。また、データ表示部分の親要素にcontain: layoutを当てる、画像にwidth/heightを指定する等のCSS側の対策も併用してください。
まとめ〜サーバー状態管理の新時代へ
本記事ではTanStack Query v5のQueryClient設定からSSR連携まで35本超の実コードでカバーしました。最重要のポイントを5つに圧縮するなら、(1) サーバー状態とクライアント状態は別物と認識し責務分担する、(2) queryKeyは階層配列+Query Key Factoryで管理する、(3) staleTimeとgcTimeの違いを理解しデータ種別で使い分ける、(4) 楽観的更新はonMutate→onError→onSettledの3点セットで書く、(5) Devtoolsを常時開いてキャッシュ状態を可視化する、です。これらをチームで共有できれば、APIまわりのコードが半分以下に減り、ユーザー体験(ローディング体感・タブ復帰時の即時表示・楽観的更新の滑らかさ)が劇的に改善されます。次のステップはReact状態管理ライブラリ完全比較で自分のプロジェクトのクライアント状態側もモダン化すること、そしてTypeScript完全実践ガイドのジェネリクスをマスターしてqueryFnを完全に型安全にすることです。TanStack Queryは小さく始めて段階的に広げられるため、今日の1画面にuseQueryを1個導入することから始めてみてください。

コメント