useEffect + useState でデータ取得、書けるけどつらい。
キャッシュもない、ローディング管理も毎回手書き、同じコードのコピペ地獄。
TanStack Query を使えば、それ全部 3 行で終わります。この記事では「なぜ useEffect がつらいのか」から始めて、TanStack Query の基本から楽観的更新まで一気に解説します。
TanStack Query(React Query)v5 とは
TanStack Query(旧 React Query)は、React アプリケーションにおけるサーバーステート管理を劇的にシンプルにするライブラリです。
「サーバーステート」とは、API から取得するデータのことです。たとえば、ユーザー一覧や投稿データなど、サーバー側に本体があり、フロントエンドではそのコピーを表示しているデータを指します。こうしたデータは「いつ古くなるか」「誰かが裏で更新していないか」といった問題があり、フロントエンドだけの状態(モーダルの開閉やフォームの入力値など)とは性質がまったく異なります。
TanStack Query を使うと、データの取得・キャッシュ・再取得・エラー処理といった面倒な処理を、ほんの数行のコードで実現できます。
v5 は 2023年10月にリリースされたメジャーバージョンで、v4 から多くの改善が行われました。主な特徴は以下のとおりです:
-
API の簡素化 — オーバーロードの廃止、オプション名の整理(
cacheTime→gcTimeなど)により、一貫性のある API に -
Suspense の正式サポート —
useSuspenseQueryが first-class API として追加され、React Suspense との統合が安定化 -
TypeScript 体験の向上 — エラー型のデフォルトが
Errorに、queryOptionsによる型安全なクエリ定義の共有が可能に - バンドルサイズの削減 — 約20%の軽量化
インストール方法
npm install @tanstack/react-query @tanstack/react-query-devtools
v5 では React 18 以上が必須です(useSyncExternalStore を使用するため)。
なぜ TanStack Query が必要なのか
従来の React アプリでは、API からデータを取得するために useEffect と useState を組み合わせて、ローディング状態・エラー状態・データ本体をそれぞれ自分で管理する必要がありました。
// 従来の方法:自分で全部管理しなければならない
const [data, setData] = useState<Todo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch("/api/todos")
.then((res) => res.json())
.then(setData)
.catch(setError)
.finally(() => setIsLoading(false));
}, []);
この書き方には以下の問題があります:
- 毎回同じようなコードを書く必要がある(ボイラープレートが多い)
- キャッシュの仕組みがない — 同じデータを複数のコンポーネントで使うと、それぞれが個別に API を呼んでしまう
- データの鮮度管理ができない — 一度取得したデータがいつまでも古いまま表示される
- 画面を離れて戻ったときの再取得など、細かい制御を自分で実装する必要がある
TanStack Query を使えば、これらの問題がすべて解消されます。
// TanStack Query:キャッシュ・再取得・エラー処理を自動で管理してくれる
const { data, isPending, error } = useQuery({
queryKey: ["todos"],
queryFn: () => fetch("/api/todos").then((res) => res.json()),
});
たったこれだけで、ローディング状態の管理、エラーハンドリング、キャッシュ、バックグラウンドでの再取得が全て自動で行われます。
Redux や Context で API データを管理しているプロジェクトも多いですが、サーバーから取得するデータは TanStack Query のような専用ツールに任せ、Redux/Context はフロントエンド固有の状態(UI の状態など)だけに使うのがおすすめです。
セットアップ
TanStack Query を使うには、アプリのルート(一番外側)に QueryClientProvider というコンポーネントを配置します。これは「アプリ全体で TanStack Query を使えるようにする設定」だと思ってください。
// workspace/App.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
// QueryClient はコンポーネントの「外側」で作成する(再レンダリングのたびに作り直されないようにするため)
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1分間はキャッシュを「新鮮」とみなす(この間は再取得しない)
gcTime: 1000 * 60 * 5, // 5分間キャッシュをメモリに保持する
retry: 2, // API 呼び出しが失敗したら2回まで自動リトライ
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
{/* アプリ本体 */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
ReactQueryDevtools は開発時にキャッシュの状態をブラウザ上で確認できるツールです。本番ビルドでは自動的に除外されるので、入れておいて損はありません。
デフォルト設定の補足
上記の設定項目について、もう少し詳しく説明します。
| オプション | デフォルト値 | 説明 |
|---|---|---|
staleTime |
0(即座に古いと判断) |
データが「古い(stale)」と見なされるまでの時間。古くなったデータは、次にそのデータが必要になったときに自動で再取得される |
gcTime |
5分 |
使われなくなったキャッシュがメモリから削除されるまでの時間(v4 では cacheTime という名前だった) |
retry |
3 |
API 呼び出しが失敗したときのリトライ回数。間隔は自動的に長くなる(指数バックオフ) |
refetchOnWindowFocus |
true |
別のタブから戻ってきたときに、古いデータを自動で再取得する |
refetchOnReconnect |
true |
ネットワークが切れて復帰したときに、自動で再取得する |
staleTime と gcTime の違いがわかりにくいかもしれません。簡単に言うと:
- staleTime = 「このデータはまだ新しいから、再取得しなくていいよ」という期間
- gcTime = 「このデータはもう画面で使われてないけど、メモリには残しておくよ」という期間
useQuery によるデータ取得
useQuery は TanStack Query の最も基本的なフックです。API からデータを取得するときに使います。
const { data, isPending, isError, error } = useQuery({
queryKey: ["todos"], // このデータを識別するためのキー(キャッシュのキーになる)
queryFn: fetchTodos, // データを取得する関数
});
if (isPending) return <p>読み込み中...</p>;
if (isError) return <p>エラー: {error.message}</p>;
return <ul>{data.map((todo) => <li key={todo.id}>{todo.title}</li>)}</ul>;
queryKey はキャッシュを識別するためのキーです。同じ queryKey を持つ useQuery が複数のコンポーネントにあっても、API の呼び出しは1回だけ行われ、結果が共有されます。これがキャッシュの仕組みです。
queryFn は実際にデータを取得する関数です。fetch や axios など、Promise を返す関数を指定します。
v5 での変更点(v4 経験者向け)
- ステータスが
loading→pendingに変更。旧isLoadingの役割はisPendingが引き継ぎ、新しいisLoadingはisPending && isFetching(初回読み込み中のみtrue)として再定義 - オーバーロード廃止(常にオブジェクト形式で書く)
- エラーのデフォルト型が
Errorに(v4 ではunknown)
queryOptions でクエリ定義を共有する
同じデータ取得処理を複数の場所で使いたい場合、queryOptions を使ってクエリの定義を1箇所にまとめることができます。v5 で追加された機能です。
// workspace/hooks/useTodos.ts
import { queryOptions, useSuspenseQuery } from "@tanstack/react-query";
import { fetchTodos } from "../api/todos";
// クエリの定義を1箇所にまとめる
export const todosQueryOptions = queryOptions({
queryKey: ["todos"],
queryFn: fetchTodos,
});
// この定義を useQuery でも prefetchQuery でも使い回せる
export const useTodos = () => useSuspenseQuery(todosQueryOptions);
こうしておけば、queryKey のタイポや queryFn の書き間違いを防げますし、TypeScript の型推論も正しく効きます。
useSuspenseQuery で Suspense 対応
React には Suspense という仕組みがあり、「データ読み込み中の表示」をコンポーネントの外側で宣言的に書けます。TanStack Query v5 では、この Suspense に対応した useSuspenseQuery フックが正式に用意されました。
まず、親コンポーネントで「読み込み中」と「エラー時」の表示を定義します。
import { Suspense } from "react";
import { ErrorBoundary } from "react-error-boundary";
// 親コンポーネント:ローディングとエラーの表示を担当
<ErrorBoundary fallback={<p>エラーが発生しました</p>}>
<Suspense fallback={<p>読み込み中...</p>}>
<TodoList />
</Suspense>
</ErrorBoundary>
子コンポーネントでは、ローディングやエラーのことを気にせず、データがある前提のコードだけを書けます。
// workspace/components/TodoList.tsx
import { useTodos } from "../hooks/useTodos";
export const TodoList = () => {
// data は常に存在する(undefined にならない!)
const { data: todos } = useTodos();
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
};
通常の useQuery では data が undefined かもしれないので毎回チェックが必要ですが、useSuspenseQuery では data が必ず存在することが型レベルで保証されます。これにより、コンポーネントのコードがすっきりします。
useSuspenseQuery のポイント:
-
dataが常にT型(T | undefinedではない) - ローディング・エラー状態のハンドリングが不要(親の Suspense/ErrorBoundary に任せる)
-
enabledオプションは使用不可(Suspense の仕組みと矛盾するため)
useMutation でデータの更新
ここまではデータの「取得(読み取り)」でしたが、データの「作成・更新・削除」には useMutation を使います。
// workspace/hooks/useTodos.ts より抜粋
export const useCreateTodo = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateTodoInput) => createTodo(input),
onSuccess: () => {
// 作成が成功したら、Todo一覧のキャッシュを無効化して再取得させる
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
};
invalidateQueries は「このキャッシュはもう古いので、次に必要になったら再取得してね」と TanStack Query に伝える関数です。画面に表示中であれば即座に再取得が走ります。
コンポーネント側では以下のように使います。
// workspace/components/AddTodo.tsx
const createMutation = useCreateTodo();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate({ title: "新しいTodo" });
};
// isPending を使ってボタンを無効化し、二重送信を防止
<button disabled={createMutation.isPending}>
{createMutation.isPending ? "追加中..." : "追加"}
</button>
楽観的更新(Optimistic Updates)
通常、データを更新すると「送信 → サーバーで処理 → 成功を受け取る → 画面を更新」という流れになります。しかしこれだと、サーバーの応答を待つ間、ユーザーは画面が変わらず待たされます。
楽観的更新とは、「サーバーの応答を待たずに、成功するだろうと見込んで先に画面を更新してしまう」テクニックです。もしサーバー側で失敗した場合は、元の状態にロールバック(巻き戻し)します。SNS の「いいね」ボタンなどでよく使われるパターンです。
v5 では2つのアプローチが用意されています。
方法1: キャッシュを直接操作する(複数箇所で反映が必要な場合)
この方法は、同じデータを表示している複数のコンポーネントすべてに即座に反映したい場合に使います。
// workspace/hooks/useTodos.ts より
export const useCreateTodo = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: CreateTodoInput) => createTodo(input),
onMutate: async (newTodo) => {
// 1. 進行中の再取得をキャンセル(楽観的に書き換えたデータが上書きされるのを防ぐ)
await queryClient.cancelQueries({ queryKey: ["todos"] });
// 2. 現在のキャッシュを保存しておく(失敗時にロールバックするため)
const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]);
// 3. キャッシュを楽観的に更新(まだサーバーには反映されていない)
queryClient.setQueryData<Todo[]>(["todos"], (old) => [
{ id: Date.now(), title: newTodo.title, completed: false },
...(old ?? []),
]);
// 4. ロールバック用のデータを返す
return { previousTodos };
},
onError: (_err, _newTodo, context) => {
// サーバー側で失敗した場合、保存しておいたデータに戻す
if (context?.previousTodos) {
queryClient.setQueryData(["todos"], context.previousTodos);
}
},
onSettled: () => {
// 成功・失敗に関わらず、サーバーから最新データを再取得して確実に同期する
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
};
方法2: variables を使って UI を直接更新する(シンプルなケース)
1箇所だけ表示を変えたい場合は、もっとシンプルに書けます。
const mutation = useMutation({ mutationFn: updateTodo });
// mutation.variables で「いま送信中のデータ」にアクセスできる
<span>
{mutation.isPending
? mutation.variables.title // 送信中は、送ったデータを先に表示
: todo.title} // それ以外はサーバーのデータを表示
</span>
方法2はコードが少なくロールバック処理も不要ですが、そのコンポーネント内でしか反映されません。複数コンポーネントに反映が必要な場合は方法1を使いましょう。
v4 からの主な破壊的変更まとめ
すでに v4 を使っている方向けに、v5 での主な変更点をまとめます。
| v4 | v5 | 理由 |
|---|---|---|
cacheTime |
gcTime |
ガベージコレクション(不要データの自動削除)の意図を正確に表現 |
keepPreviousData |
placeholderData に統合 |
API の簡素化 |
isLoading |
isPending(旧 isLoading の役割)。新 isLoading = isPending && isFetching
|
ステータス loading → pending に変更。isLoading は初回読み込み中のみ true に再定義 |
useErrorBoundary |
throwOnError |
機能を正確に反映 |
| オーバーロード対応 | オブジェクト形式のみ | API の一貫性 |
onSuccess / onError (useQuery) |
削除 | 詳細は公式ブログ参照 |
v4 からの移行には公式のコードモッド(自動変換ツール)が用意されています。オーバーロードの除去を自動で行えます。
# TypeScript/TSX ファイルの場合
npx jscodeshift@latest ./path/to/src/ \
--extensions=ts,tsx \
--parser=tsx \
--transform=./node_modules/@tanstack/react-query/build/codemods/src/v5/remove-overloads/remove-overloads.cjs
コードモッドは全てのケースをカバーできるわけではないので、適用後はコードを必ずレビューしてください。
まとめ
TanStack Query v5 は、React アプリにおけるサーバーデータ管理の定番ライブラリです。
- useQuery: API データの取得・キャッシュ・自動再取得をまとめて管理
- useSuspenseQuery: React Suspense と組み合わせて、ローディング処理をコンポーネントの外に分離
- useMutation: データの作成・更新・削除と、楽観的更新のサポート
- queryOptions: クエリ定義を1箇所にまとめて、型安全に使い回す
「useEffect + useState でのデータ取得がつらい」と感じたら、TanStack Query の導入を検討してみてください。