React におけるスマートなデータフェッチングのための TanStack Query の活用

API と連携する React アプリを構築したことがあれば、このパターンをご存知でしょう:フェッチには useEffect
、データには useState
、ローディング用にもう一つの useState
、そしてエラー用にもう一つ。気がつくと、ユーザーリストを表示するだけのために、もつれた状態の管理に追われることになります。
この手動アプローチは機能しますが、脆弱です。ユーザーが画面から離れて戻ってきたときはどうなるでしょうか?再フェッチしますか?古いデータを使いますか?ネットワークが失敗したときのリトライはどうでしょうか?これらはエッジケースではありません。本番アプリの現実なのです。
TanStack Query(旧 React Query)は、サーバー状態をクライアント状態とは異なるものとして扱うことで、これらの問題を解決します。データを命令的にフェッチする代わりに、必要なものを宣言し、ライブラリにキャッシュ、同期、更新を処理させます。この記事では、手動データフェッチングから、スケールするスマートで宣言的なアプローチへの移行方法を紹介します。
重要なポイント
- 手動の
useEffect
+useState
パターンを宣言的なuseQuery
フックに置き換えて、よりクリーンで保守しやすいコードを実現 - 自動キャッシュ、バックグラウンド再フェッチ、リクエストの重複排除を活用してユーザーエクスペリエンスを向上
- オプティミスティック更新を使用したミューテーションで、瞬時に感じるレスポンシブな UI を作成
- 適切なクエリ無効化戦略を実装して、アプリケーション全体でデータの同期を維持
- ローカル状態に TanStack Query を使用したり、クエリキーの動的パラメータを忘れたりするような一般的な落とし穴を回避
手動データフェッチングの問題
典型的な React コンポーネントでデータをフェッチする例を見てみましょう:
import { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
async function fetchUsers() {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
if (!cancelled) {
setUsers(data);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
fetchUsers();
return () => { cancelled = true };
}, []);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
これは動作しますが、管理している内容を見てください:
- 3つの個別の状態
- アンマウントされたコンポーネントでの状態更新を防ぐクリーンアップロジック
- キャッシュなし—マウントのたびに新しいフェッチが発生
- 失敗したリクエストのリトライロジックなし
- データが古いかどうかを知る方法なし
この複雑さを数十のコンポーネントに掛け合わせてみてください。そこで TanStack Query の出番です。
TanStack Query がデータフェッチングをスマートにする方法
TanStack Query は、サーバー状態を第一級市民として扱います。フェッチを手動で調整する代わりに、データ要件を宣言的に記述します:
import { useQuery } from '@tanstack/react-query';
function UserList() {
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
}),
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
同じ機能ですが、欠けているものに注目してください:
- データ、ローディング、エラー用の
useState
がない useEffect
やクリーンアップロジックがない- 手動の状態同期がない
しかし、以下を得られます:
- 自動キャッシュ: 画面から離れて戻っても、瞬時に結果が表示
- バックグラウンド再フェッチ: 古いデータが静かに更新
- リクエストの重複排除: 複数のコンポーネントが同じクエリを共有可能
- 組み込みリトライロジック: 失敗したリクエストが自動的にリトライ
- オプティミスティック更新: サーバー確認前に UI が更新
スマートなデータ管理のためのコア概念
クエリ:サーバー状態の読み取り
クエリはデータをフェッチしてキャッシュします。useQuery
フックは、2つの必須プロパティを持つ設定オブジェクトを受け取ります:
const { data, isLoading, error } = useQuery({
queryKey: ['todos', userId, { status: 'active' }],
queryFn: () => fetchTodosByUser(userId, { status: 'active' }),
staleTime: 5 * 60 * 1000, // 5分
gcTime: 10 * 60 * 1000, // 10分(旧 cacheTime)
});
クエリキーは各クエリを一意に識別します。以下を含む配列です:
- 静的識別子:
['users']
- 動的パラメータ:
['user', userId]
- 複雑なフィルタ:
['todos', { status, page }]
クエリキーの任意の部分が変更されると、TanStack Query は新しいデータをフェッチする必要があることを認識します。
ミューテーション:サーバー状態の変更
クエリがデータを読み取る一方で、ミューテーションはデータを変更します:
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateTodo() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (newTodo) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
headers: { 'Content-Type': 'application/json' },
}).then(res => {
if (!res.ok) throw new Error('Failed to create todo');
return res.json();
}),
onSuccess: () => {
// 無効化して再フェッチ
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<button
onClick={() => mutation.mutate({ title: 'New Todo' })}
disabled={mutation.isPending}
>
{mutation.isPending ? 'Creating...' : 'Create Todo'}
</button>
);
}
ミューテーションは完全なライフサイクルを処理します:ローディング状態、エラーハンドリング、成功コールバック。onSuccess
コールバックは、変更後にキャッシュを更新するのに最適です。
クエリ無効化:データの鮮度維持
無効化はクエリを古いものとしてマークし、バックグラウンド再フェッチをトリガーします:
// すべてを無効化
queryClient.invalidateQueries();
// 特定のクエリを無効化
queryClient.invalidateQueries({ queryKey: ['todos'] });
// 完全一致で無効化
queryClient.invalidateQueries({
queryKey: ['todo', 5],
exact: true
});
これが TanStack Query がミューテーション後に UI を同期させる方法です。Todo を変更しましたか?Todo リストを無効化します。ユーザーを更新しましたか?そのユーザーのデータを無効化します。
React アプリでの TanStack Query のセットアップ
まず、パッケージをインストールします:
npm install @tanstack/react-query
次に、QueryClient プロバイダーでアプリをラップします:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1分
gcTime: 5 * 60 * 1000, // 5分(旧 cacheTime)
retry: 3,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
);
}
QueryClient
はすべてのキャッシュと同期を管理します。通常、アプリ全体で1つのインスタンスを作成します。
本番アプリのための高度なパターン
オプティミスティック更新
UI を即座に更新し、その後サーバーと同期します:
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// 送信中の再フェッチをキャンセル
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 以前の値のスナップショット
const previousTodos = queryClient.getQueryData(['todos']);
// オプティミスティック更新
queryClient.setQueryData(['todos'], old =>
old ? [...old, newTodo] : [newTodo]
);
// ロールバック用のコンテキストを返す
return { previousTodos };
},
onError: (err, newTodo, context) => {
// エラー時にロールバック
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
onSettled: () => {
// エラーまたは成功後に常に再フェッチ
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
依存クエリ
相互に依存するクエリをチェーンします:
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: () => getUserByEmail(email),
});
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => getProjectsByUser(user.id),
enabled: !!user?.id, // ユーザー ID が存在する場合のみ実行
});
無限クエリ
無限スクロールでページネーションデータを処理します:
import { useInfiniteQuery } from '@tanstack/react-query';
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['projects'],
queryFn: ({ pageParam = 0 }) => fetchProjects({ page: pageParam }),
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
initialPageParam: 0,
});
よくある落とし穴と解決策
QueryClientProvider を忘れる
クエリが undefined を返したり、まったく動作しない場合は、アプリが QueryClientProvider
でラップされているかを確認してください。これは最も一般的なセットアップミスです。
ローカル状態に TanStack Query を使用する
TanStack Query はサーバー状態用です。フォーム入力、UI トグル、その他のクライアント専用状態には使用しないでください。それらには useState
や useReducer
を使い続けてください。
動的データでの静的クエリキー
動的パラメータを常にクエリキーに含めてください:
// ❌ 間違い - すべてのユーザーで同じデータが表示される
useQuery({
queryKey: ['user'],
queryFn: () => fetchUser(userId),
});
// ✅ 正しい - ユーザーごとに一意のキャッシュエントリ
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
まとめ
TanStack Query は、React のデータフェッチングを手動でエラーが起こりやすいプロセスから、宣言的で堅牢なシステムに変換します。サーバー状態をクライアント状態とは根本的に異なるものとして扱うことで、ボイラープレートを排除しながら、キャッシュ、同期、オプティミスティック更新などの強力な機能を追加します。
メンタルシフトは簡単です:データをどのようにフェッチするかを考えるのをやめて、どのデータが必要かを考え始めることです。クエリキーと関数を通じて要件を宣言し、TanStack Query にすべてを同期させる複雑さを処理させましょう。
小さく始めましょう—1つの useEffect
フェッチを useQuery
に置き換えてください。瞬時のローディング状態、自動リトライ、シームレスなキャッシュが動作するのを見れば、TanStack Query が現代の React アプリケーションにとって不可欠になった理由が理解できるでしょう。
よくある質問
同じライブラリです。React Query は、React 以外の複数のフレームワークをサポートするようになったことを反映して、TanStack Query に名前が変更されました。React 専用のパッケージは引き続き保守されており、馴染みのある同じ API を使用しています。
はい、ただしサーバー状態にはおそらく必要ありません。TanStack Query は、コンポーネント間でのキャッシュとデータ共有を自動的に処理します。ユーザー設定や UI 状態などの真のクライアント状態には Redux や Context を使用し、API から取得するものには TanStack Query を使用してください。
デフォルトでは、クエリはウィンドウフォーカス、再接続、マウント時に再フェッチします。staleTime(データが新鮮な状態を保つ時間)と gcTime(未使用データを保持する時間)で鮮度を制御できます。古いクエリは、キャッシュされたデータを即座に表示しながら、バックグラウンドで再フェッチします。
無効化はクエリを古いものとしてマークしますが、すぐには再フェッチしません—クエリが再び使用されるまで待ちます。直接の再フェッチは即座にネットワークリクエストをトリガーします。パフォーマンス向上のため、ミューテーション後は無効化を使用してください。実際に表示されているクエリのみを再フェッチするためです。
TanStack Query は、ポーリングと再フェッチ間隔を通じて準リアルタイム更新に優れています。真のリアルタイムニーズには、WebSocket と組み合わせてください:ライブ更新にはソケットを使用し、初期データロードとソケット切断時のフォールバックには TanStack Query を使用します。
はい。TanStack Query 5+ は React 19 と完全に互換性があります。ここで使用されている API(クエリ、ミューテーション、無効化)は変更されていないため、この記事の例は変更なしで動作します。