TanStack Query(React Query)
前回の授業で、トムはカスタムフックとaxiosを使ってHTTPリクエストをラップしましたが、新たな問題が発生しました。ダッシュボードとサイドバーの両方にユーザー数が表示されており、各コンポーネントが独自にリクエストを送信しているため(帯域幅とサーバーリソースを浪費しています)、ユーザーがページAでユーザー名を変更しても、ページBのデータは更新されないままになります。また、編集フォームを送信した後、ユーザーは手動でデータの更新を実行しなければなりません。彼は次のように気づきました。APIデータを、キャッシュされ、有効期限があり、自動的に同期できる特別な状態として扱うためには、「サーバーサイドの状態管理」ソリューションが必要だ。
1. 学習内容
- useQuery はデータの取得(フェッチ、キャッシュ、自動再取得)を管理します
- useMutation はデータの書き込み(挿入、削除、更新、および古いデータに対するクエリ)を管理します
- staleTime / cacheTime はキャッシュポリシーを制御します
- オプティミスティック更新により、UIのリアルタイムな応答性が実現されます
- サーバーサイドの状態とクライアントサイドの状態の区別
2. 概念図
flowchart TD
A[useQuery Call] --> B{Cache Hit?}
B -->|No matches found| C[Initiate API Request]
B -->|Hit but Expired| C
B -->|On Target and Fresh| D[Return cached data directly]
C --> E[Cached Data]
E --> F[Rendering Component]
F --> G{staleTime Has it expired??}
G -->|Not yet due| H[Data labeled as"Fresh"]
G -->|Expired| I[Data labeled as"Expired"]
I --> J{Bring the window back to the foreground?}
J -->|is | C
I --> K{refetchInterval?}
K -->|is | C
I --> L{There's something new useMutation<br/>invalidate?}
L -->|is | C
style A fill:#e1f5fe,stroke:#0288d1
style E fill:#fff3e0,stroke:#f57c00
style H fill:#e8f5e9,stroke:#388e3c
style I fill:#ffcdd2,stroke:#d32f2f
TanStack Queryの基本的な仕組み:まずキャッシュから読み込む → 有効か期限切れかを判定 → 期限切れの場合は自動的に新しいリクエストをトリガーする。
3. 実際の事例
トムのダッシュボードには、ユーザー総数、注文総数、および最近の注文一覧を表示する必要があります。これらのデータは3つの異なるAPIから取得されるものであり、リアルタイムで最新の状態に保たれる必要があります。つまり、ユーザーが別のページでデータを変更してダッシュボードに戻った際、最新の結果が表示されるようにする必要があります。さらに、「商品を追加」フォームを送信した後、手動でページを更新することなく、商品リストが自動的に更新されるようにする必要があります。
(1) 問題:キャッシュの手動管理が難しすぎる
TanStack Queryがなければ、トムは自分でそれを処理しなければならなかった:
// Manually Manage the Cache — You have to write similar logic for each component.
function Dashboard() {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/stats').then(res => res.json())
.then(d => { setData(d); setLoading(false) })
.catch(e => { setLoading(false) })
}, [])
// Question 1:Switch to another page and then come back → Resubmit Request(Waste!)
// Question 2:If another component also needs the same data → Duplicate Request
// Question 3:The data does not refresh automatically,Users may be seeing data from a few minutes ago
}
TanStack Query は、たった 1 つの useQuery フックで、上記のすべての問題を解決します。
| 特集 | 手動フェッチ + useEffect | TanStack Query |
|---|---|---|
| キャッシュ管理 | キャッシュなし。削除時にデータが失われる | 自動キャッシュ。queryKey によって管理される |
| 重複リクエスト | 同じデータを使用する複数のコンポーネント → 重複リクエスト | 重複を自動的に削除し、リクエストは1回のみ |
| 自動更新 | なし | ウィンドウのフォーカス変更時/再接続時の自動取得 |
| バックエンドの更新 | なし | staleTime でバックエンドの更新間隔を制御 |
| 読み込み/エラーの状態 | 手動で管理される読み込み/エラー | 自動的に提供されるデータ/読み込み/エラー |
| 楽観的な最新情報 | 手動での実装 | onMutate + onError のロールバック |
npm install @tanstack/react-query
(2) プロバイダーの初期化
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
// Create QueryClient(Usually at app Entrance)
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000, // Default 30 Data within seconds is considered"Fresh"
cacheTime: 5 * 60 * 1000, // Cache Retention 5 minutes
retry: 3, // Failure retry 3 times
refetchOnWindowFocus: true, // Refresh when the window returns to the foreground
}
}
})
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
</QueryClientProvider>
)
}
4. useQuery:データの取得
(1) ▶ サンプル:ダッシュボードデータの取得
import { useQuery } from '@tanstack/react-query'
// Encapsulated API Function
async function fetchDashboardStats() {
const response = await fetch('/api/dashboard/stats')
if (!response.ok) throw new Error('Failed to retrieve statistics')
return response.json()
}
function Dashboard() {
// useQuery One line of code replaced useState + useEffect + Manual Caching
const {
data: stats, // Response Data
isLoading, // Loading for the first time(When there is no cache)
isFetching, // Is a request being made?(Including background retries)
error, // Error Object
refetch // Manually Trigger Retrieval
} = useQuery({
queryKey: ['dashboardStats'], // Unique Identifier,Used for caching matches
queryFn: fetchDashboardStats, // Data Retrieval Functions
staleTime: 30 * 1000, // 30 Do not resend the request within seconds
retry: 3, // Failure retry 3 times
})
if (isLoading) return <DashboardSkeleton />
if (error) return <ErrorPanel message={error.message} onRetry={refetch} />
return (
<div className="dashboard">
<StatCard title="Total Number of Users" value={stats.users} />
<StatCard title="Total Number of Orders" value={stats.orders} />
<StatCard title="Total Revenue" value={`$${stats.revenue}`} />
</div>
)
}
// The sidebar also displays the number of users — Using the same queryKey,No duplicate requests!
function Sidebar() {
const { data: stats } = useQuery({
queryKey: ['dashboardStats'],
queryFn: fetchDashboardStats,
staleTime: 30 * 1000,
})
return (
<aside>
<p>Users Online:{stats?.onlineUsers ?? '...'}</p>
</aside>
)
}
主な仕組み:同一の queryKey インスタンスは同じキャッシュを共有します。 ダッシュボードとサイドバーは同じ ['dashboardStats'] を使用しており、TanStack Query がリクエストの重複を自動的に排除します。各コンポーネントは 1 回の API リクエストのみを発行し、データが返されると両方のコンポーネントが同時に更新されます。
(2) ▶ サンプル:パラメータを含むクエリ
function ProductDetail({ productId }) {
const { data, isLoading, error } = useQuery({
queryKey: ['product', productId], // queryKey Includes parameters
queryFn: async () => {
const res = await fetch(`/api/products/${productId}`)
if (!res.ok) throw new Error('The product does not exist.')
return res.json()
},
enabled: !!productId, // productId Do not send a request if it is empty
staleTime: 60 * 1000,
})
if (isLoading) return <p>Loading product details...</p>
if (error) return <p>Error:{error.message}</p>
return (
<div>
<h2>{data.name}</h2>
<p className="price">${data.price}</p>
<p>{data.description}</p>
</div>
)
}
queryKeyに含まれるパラメータの意義: TanStack Queryでは、queryKeyをキャッシュの一意の識別子として使用します。['product', 1]と['product', 2]は、互いに干渉しない2つの独立したキャッシュです。productIdが1から2に変化すると、システムはキャッシュからの['product', 2]の読み取りを優先します。キャッシュされており、有効期限が切れていない場合は直接レンダリングされ、そうでない場合はリクエストを送信します。
(3) ▶ サンプル:useQuery によって返される主要フィールド
| フィールド | 意味 | ユースケース |
|---|---|---|
data |
最後に正常に応答した際のデータ | UIの表示 |
isLoading |
キャッシュデータがない場合の初回読み込み | 初回読み込み時にスケルトン画面を表示 |
isFetching |
進行中のリクエスト(バックグラウンドでの再試行を含む) | バックグラウンド更新インジケーターを表示 |
error |
失敗したリクエストのエラーオブジェクト | エラーメッセージを表示 |
refetch |
手動で再リクエストを実行する機能 | 「更新」ボタン |
isStale |
データは古くなっていませんか? | 条件に応じて更新の案内を表示 |
5. useMutation:データの書き込み
読み取りには useQuery を、書き込みには useMutation を使用してください。これが TanStack Query の鉄則です。
(1) ▶ サンプル:商品を追加してリストを更新する
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
// Product List Search
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: async () => {
const res = await fetch('/api/products')
return res.json()
}
})
}
function ProductManager() {
const queryClient = useQueryClient()
// Add a product mutation
const addProductMutation = useMutation({
mutationFn: async (newProduct) => {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newProduct)
})
if (!res.ok) throw new Error('Failed to add')
return res.json()
},
// Steps to Take After Success:Invalidate the product list cache,Trigger a re-request
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['products'] })
// Optional:Display a success message at the same time
alert('Item added successfully!')
},
// Error Handling
onError: (error) => {
alert(`Failed to add:${error.message}`)
}
})
// Data
const { data: products, isLoading } = useProducts()
// Form Submission
function handleSubmit(event) {
event.preventDefault()
const formData = new FormData(event.target)
const newProduct = {
name: formData.get('name'),
price: Number(formData.get('price'))
}
addProductMutation.mutate(newProduct)
event.target.reset()
}
return (
<div>
<h2>Product Management</h2>
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Product Name" required />
<input name="price" type="number" placeholder="Price" required />
<button type="submit" disabled={addProductMutation.isLoading}>
{addProductMutation.isLoading ? 'Submitting......' : 'Add Item'}
</button>
</form>
{addProductMutation.isError && (
<p className="error">Submission Failed:{addProductMutation.error.message}</p>
)}
<hr />
<h3>Product List</h3>
{isLoading && <p>Loading......</p>}
{products && (
<ul>
{products.map(p => (
<li key={p.id}>{p.name} — ${p.price}</li>
))}
</ul>
)}
</div>
)
}
主な処理の流れ: useMutation.mutate() が POST リクエストを送信 → サーバーがリクエストを処理 → onSuccess がコールバック内で invalidateQueries を実行 → TanStack Query が自動的に ['products'] のデータを再取得 → リスト UI が自動的に更新される。
setDataを手動で利用してみてはいかがでしょうか? queryClient.setQueryDataを呼び出してキャッシュを手動で更新することも可能ですが、より良い方法は、TanStack QueryにinvalidateQueriesを介してサーバーからデータを再リクエストさせることです。これにより、データが常にサーバーから取得されることが保証され、フロントエンドのキャッシュとサーバー側のデータとの間に不整合が生じるのを防ぐことができます。
6. 楽観的更新
ネットワークの遅延が大きい場合、ユーザーはリクエストを送信した後、サーバーからの応答を待たなければ UI の変更を確認できず、その結果、ユーザー体験が低下してしまいます。楽観的更新では、リクエストを送信する前に UI を即座に更新し、リクエストが失敗した場合は以前の状態にロールバックします。
(1) ▶ サンプル:タスクの完了ステータスの切り替え
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
// Get the To-Do List
function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: async () => {
const res = await fetch('/api/todos')
return res.json()
}
})
}
function TodoList() {
const queryClient = useQueryClient()
const { data: todos } = useTodos()
// Switch to "Completed" status — Using Optimistic Updates
const toggleMutation = useMutation({
// Actual API Request
mutationFn: async ({ id, done }) => {
const res = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done })
})
if (!res.ok) throw new Error('Update Failed')
return res.json()
},
// ===== Optimistic Update Begins =====
onMutate: async ({ id, done }) => {
// 1. Cancel all ongoing todos Search(Avoid Overwriting Optimistic Updates)
await queryClient.cancelQueries({ queryKey: ['todos'] })
// 2. Save the current cache data,Used for rollback in case of failure
const previousTodos = queryClient.getQueryData(['todos'])
// 3. Refresh the cache immediately
queryClient.setQueryData(['todos'], (old) =>
old.map(todo =>
todo.id === id ? { ...todo, done } : todo
)
)
// 4. Restore old data for rollback
return { previousTodos }
},
// ===== End of Optimistic Update =====
// Rollback on Failure
onError: (err, variables, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos)
}
},
// Whether we succeed or fail,Finally, resend the request to ensure synchronization with the server.
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
})
return (
<ul>
{todos?.map(todo => (
<li key={todo.id} style={{ opacity: toggleMutation.isLoading ? 0.7 : 1 }}>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleMutation.mutate({ id: todo.id, done: !todo.done })}
/>
<span style={{ textDecoration: todo.done ? 'line-through' : 'none' }}>
{todo.title}
</span>
</li>
))}
</ul>
)
}
楽観主義への3ステップガイド:
onMutate: リクエストを送信する直前にキャッシュを更新し、ユーザーが変更内容をすぐに確認できるようにする。また、ロールバックが必要になった場合に備えて、古いデータを保存しておく。onError: リクエストが失敗した際に、保存された履歴データを使用してキャッシュを復元(ロールバック)するonSettled: 成功・失敗にかかわらず、最終的にはサーバーからデータを再度取得し、完全な一貫性を確保する。
7. サーバーサイドの状態とクライアントサイドの状態
TanStack Query の背後にある概念を理解するには、次の 2 つの状態を区別する必要があります:
| ディメンション | サーバーの状態 | クライアントの状態 |
|---|---|---|
| ソース | バックエンドAPI/データベース | フロントエンド(ローカル、ユーザー操作) |
| 所有権 | サーバーがデータを所有する | フロントエンドがデータを所有する |
| 永続性 | データベースに保存 | メモリまたはlocalStorageに保存 |
| 同期要件 | サーバーとの同期を維持する必要がある | サーバーとの同期を維持する必要はない |
| 更新方法 | API経由での書き込み+再取得 | setStateを直接使用 |
| 管理ツール | TanStack Query | State / Redux Toolkit |
| 例 | ユーザー一覧、商品データ、注文情報 | ポップアップの表示/非表示切り替え、フォームの入力値、テーマカラー |
基本原則:
- APIから返されるデータ(ユーザー一覧、商品情報、注文状況) → TanStack Query を使用して管理
- フロントエンドのローカル状態(モーダルの開閉、入力フィールドの値、テーマ設定) → Zustand / Context / useState を使用して管理する
(1) ▶ サンプル:TanStack クエリ DevTools
TanStack Query には、開発中にすべてのクエリのキャッシュ状態、有効期限、および最終更新時刻を確認できる専用の DevTools コンポーネントが用意されています。
npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
{/* Display only in the development environment DevTools */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
DevToolsの機能:
- すべてのqueryKeysと、それに対応するキャッシュされたデータを表示する
- 各キャッシュエントリの「期限切れ」、「アクティブ」、「非アクティブ」のステータスを確認する
- 再取得、無効化、削除の操作を手動で実行する
- ネットワークの遅延とリクエストの応答サイズを監視する
(2) ▶ サンプル:QueryClientのグローバルメソッド
useQuery や useMutation に加え、queryClient オブジェクトも、コンポーネントの外部からキャッシュを操作するためのいくつかのグローバルメソッドを提供しています:
import { queryClient } from './queryClient'
// 1. Invalidate the cache(Trigger a re-request)
queryClient.invalidateQueries({ queryKey: ['products'] })
// 2. Clear All Cache(When a user logs out)
queryClient.clear()
// 3. Set the default option(Global Changes staleTime)
queryClient.setDefaultOptions({
queries: { staleTime: 60 * 1000 }
})
// 4. Retrieve Cached Data(Do not trigger a request)
const cachedData = queryClient.getQueryData(['products'])
// 5. Prefetch Data(User hover Preload when the link is reached)
queryClient.prefetchQuery({
queryKey: ['product', '42'],
queryFn: () => fetchProduct('42')
})
// After the user clicks the link,,The data is already in the cache.,Instant Rendering
// 6. Cancel the current query
queryClient.cancelQueries({ queryKey: ['product', '42'] })
データのプリフェッチ(prefetchQuery)は、ユーザー体験を向上させる効果的な手法です。ユーザーがリンクにカーソルを合わせると、事前にリクエストが送信されるため、ユーザーがクリックする時点でデータがすでに読み込まれており、「瞬時読み込み」のような効果が得られます。
(3) ▶ サンプル:QueryClientの設定をカスタマイズすべき場合
初期化時に定義された defaultOptions はグローバルなデフォルト値を設定しますが、各 useQuery の呼び出しで個別に上書きすることができます:
// Global Default:30 Freshness Period (in Seconds)
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30 * 1000, retry: 3 }
}
})
// A Query Override:The data remains virtually unchanged,Set the shelf life to 10 minutes
function useProductCategories() {
return useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
staleTime: 10 * 60 * 1000, // 10 Do not retry within minutes
})
}
// Another query override:High real-time requirements,Disable Caching
function useRealtimeNotifications() {
return useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
staleTime: 0, // The data expires immediately
refetchInterval: 30 * 1000, // Auto-poll every 30s
})
}
設定の原則: グローバル設定では控えめな値(短い staleTime)を設定し、データの特性に応じてクエリごとに個別に上書きします。頻繁に変化するデータ(通知、リアルタイム統計)については、短い staleTime を設定するか、ポーリングを有効にします。変化頻度の低いデータ(カテゴリ一覧、設定情報)については、リクエスト数を減らすために長い staleTime を設定します。
❓ よくある質問
useEffect + fetch を使用する場合と比べて、TanStack Query の主な利点は何ですか?queryKey を使用する場合、重複するリクエストを防ぐために重複が自動的に除外されます; (2) 自動再取得—ウィンドウがフォアグラウンドに戻ったとき、ネットワークが再接続されたとき、または定期的なポーリング中に、データが自動的に更新されます;(3) ライフサイクル管理—読み込み、エラー、データ状態の自動処理;失敗時の自動再試行;リクエストをキャンセルするためにAbortControllerを手動で実装する必要がありません。1つのuseQueryで、20行分のuseEffect + fetch + useStateを置き換えることができます。staleTimeとcacheTimeの違いは何ですか?staleTime = 0 の場合、どうなりますか?staleTime はデータの「鮮度」を制御します。この時間枠内では、データは最新とみなされ、自動的な再取得はトリガーされません。cacheTime はキャッシュの保持時間を制御します。これは、コンポーネントがアンマウントされた後、キャッシュがガベージコレクションされるまでの期間です。staleTime = 0 の場合、データは返された瞬間に期限切れとしてマークされ、使用されるたびにバックグラウンドでの再取得がトリガーされます(ただし、キャッシュされたデータが最初に返され、その後更新されます)。リクエストのジッターを避けるため、staleTime = 30s に設定することをお勧めします。useMutationのonSuccessメソッドにおいて、invalidateQueriesとsetQueryDataのどちらを選択すべきですか?invalidateQueriesです。キャッシュを無効化してTanStack Queryにサーバーからのデータ再取得を強制し、データの整合性を確保します。setQueryDataは、変更がごくわずかで、返される形式が既知の場合(クライアント側の一意のIDを生成する場合など)に適しています。ユーザーの操作結果を(サーバーからの応答を待たずに)即座に表示する必要がある場合は、setQueryDataの代わりに楽観的更新(onMutate + ロールバック)を使用してください。queryKey を購読しているコンポーネントの数がいくつであっても、送信されるリクエストは1つだけです。📖 まとめ
- useQuery はデータの取得を管理します:自動キャッシュ、リクエストの重複排除、バックグラウンドでの再取得、および失敗時の再試行
- useMutation はデータの書き込みを管理します。invalidateQueries と連携して、データの更新を自動的にトリガーします。
staleTimeはデータの鮮度を制御し、cacheTimeはキャッシュの保持期間を制御します- 更新の最適化:
onMutateでまず UI を更新し、onErrorでロールバックを行い、onSettledで最終的な同期を行うことで、ユーザーエクスペリエンスを向上させます - サーバーサイドの状態(APIデータ)にはTanStack Queryを、クライアントサイドの状態(UIの状態)にはZustand/Contextを使用する
📝 練習問題
useQueryを使用してユーザーリストコンポーネントを作成する:API としてhttps://jsonplaceholder.typicode.com/usersを使用し、キャッシュの共有を実装する(両方のコンポーネントで同じqueryKeyを使用し、リクエストが 1 回だけ送信されるようにする)。また、手動リフレッシュボタンを追加する。useMutationを使用して「商品の追加」機能を実装します。フォームが送信されると POST リクエストがトリガーされ、成功した場合は商品リストが自動的に更新され、失敗した場合はエラーメッセージが表示されます。- 「ToDo項目の完了ステータスを切り替える」ための楽観的更新を実装します。チェックボックスをクリックすると、即座にステータスが切り替わります。APIリクエストが失敗した場合は、変更がロールバックされます。コードを記述した後、ネットワーク接続を切断し、チェックボックスをクリックしてロールバックの挙動を確認してください。



