- 技術 💻
TanStack Query v5の使い方|useQuery・useMutation
TanStack Query(旧 React Query)とは、API から取ってきたデータ(サーバー状態)のキャッシュ・再取得・更新をまとめて引き受けるライブラリです。 useEffect と useState で書いていた「読み込み中・エラー・再取得・二重リクエスト防止」を、useQuery と useMutation の2つのフックに置き換えられます。
この記事では、@tanstack/react-query 5.104.0(2026年10月時点の最新版) で、QueryClientProvider の準備から useQuery・useMutation・invalidateQueries・楽観的更新までを1つの完成コードにまとめました。staleTime と gcTime の違いは実測ログで、ネットワークリクエストの回数は Vite + Playwright で数えた結果を載せています。どの状態管理ライブラリを選ぶかの比較は「Reactの状態管理の比較と使い分け」、useEffect でのデータ取得の注意点は「ReactのuseEffectの使い方」の記事にまとめているので、ここでは TanStack Query の使い方に集中します。
TanStack Query v5 の早見表
| やりたいこと | 書き方 | ポイント |
|---|---|---|
| 準備する | <QueryClientProvider client={new QueryClient()}> |
アプリの外側で1回 |
| データを取る | useQuery({ queryKey, queryFn }) |
v5 は引数がオブジェクト1つだけ |
| 読み込み中を判定 | isPending |
v4 の isLoading から改名 |
| 裏で再取得中か | isFetching |
キャッシュ表示中の再取得も true |
| 登録・更新・削除 | useMutation({ mutationFn }) → mutate(値) |
結果を待つなら mutateAsync |
| 更新後に一覧を取り直す | queryClient.invalidateQueries({ queryKey }) |
onSettled で呼ぶのが基本 |
| 送信中の値を仮表示 | mutation.variables と isPending |
v5 の簡易な楽観的更新 |
| キャッシュを先に書き換える | onMutate で setQueryData、onError で戻す |
複数画面に反映したいとき |
| 新しさの期限 | staleTime(既定 0) |
この間は再取得しない |
| キャッシュの保持時間 | gcTime(既定 5分) |
v4 の cacheTime |
| 条件がそろうまで止める | enabled: !!id |
依存するクエリに |
| 表示用に加工 | select: (d) => ... |
加工結果だけで再レンダー判定 |
| ページ切替中に前の値を出す | placeholderData: keepPreviousData |
v4 の keepPreviousData |
| 複数を並列に取る | useQueries({ queries: [...] }) |
件数が動的なとき |
| 無限スクロール | useInfiniteQuery + initialPageParam |
v5 で initialPageParam が必須 |
| Next.js でサーバー取得 | prefetchQuery → <HydrationBoundary state={dehydrate(qc)}> |
App Router |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「TanStack Query v5の使い方|useQuery・useMutation」
動作確認した環境
| 項目 | バージョン |
|---|---|
| Node.js | 22.22.2 |
| @tanstack/react-query / query-core | 5.104.0 |
| react / react-dom | 19.3.0 |
| vite | 8.3.2 |
| TypeScript | 7.0.2 |
| @playwright/test | 1.56.1(Chromium 141) |
npm i @tanstack/react-query react react-dom
npm i -D vite @vitejs/plugin-react typescript @types/react @types/react-dom
v5 は React 18 以上が必要です。 開発中は @tanstack/react-query-devtools を入れると、キャッシュの中身と fresh / stale の状態を画面で確認できます。
QueryClientProvider で準備する
// src/main.tsx
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { App } from './App'
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 0 } }, // 全クエリの既定値
})
createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
)
QueryClient はコンポーネントの外で1回だけ作ります。 コンポーネントの中で new QueryClient() すると、再レンダーのたびにキャッシュが空になります(中で作る場合は useState(() => new QueryClient()))。
useQuery・useMutation の完成コード
TODO(タスク)一覧を取得・追加・完了切り替えする完成形です。API は Vite の開発サーバーに小さな middleware を足して、メモリ上の配列を返すようにしています(応答は 300ms 遅らせています)。
// src/App.tsx
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
type Todo = { id: number; title: string; done: boolean }
async function fetchTodos(): Promise<Todo[]> {
const res = await fetch('/api/todos')
if (!res.ok) throw new Error(`HTTP ${res.status}`) // fetch は 4xx/5xx で例外を出さないので自分で投げる
return res.json()
}
async function send(method: 'POST' | 'PATCH', body: unknown) {
const res = await fetch('/api/todos', { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
if (!res.ok) throw new Error((await res.json()).message)
return res.json()
}
export const todosQuery = { queryKey: ['todos'], queryFn: fetchTodos } as const
function TodoList() {
const queryClient = useQueryClient()
const { data, isPending, isError, error, isFetching } = useQuery(todosQuery)
// 追加:v5 の variables を使う簡易な楽観的更新(キャッシュは触らない)
const add = useMutation({
mutationFn: (title: string) => send('POST', { title }),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
// 完了切り替え:onMutate でキャッシュを書き換え、失敗したら onError で戻す
const toggle = useMutation({
mutationFn: (id: number) => send('PATCH', { id }),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['todos'] })
const previous = queryClient.getQueryData<Todo[]>(['todos'])
queryClient.setQueryData<Todo[]>(['todos'], (old) => old?.map((t) => (t.id === id ? { ...t, done: !t.done } : t)))
return { previous }
},
onError: (_err, _id, context) => queryClient.setQueryData(['todos'], context?.previous),
onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
})
if (isPending) return <p>読み込み中…</p>
if (isError) return <p>エラー: {error.message}</p>
return (
<>
<p id="fetching">{isFetching ? '更新中' : '最新'}</p>
<ul id="list">
{data.map((t) => (
<li key={t.id}>
<label><input type="checkbox" checked={t.done} onChange={() => toggle.mutate(t.id)} aria-label={t.title} />{t.title}</label>
</li>
))}
{add.isPending && <li id="optimistic" style={{ opacity: 0.5 }}>{add.variables}</li>}
</ul>
{add.isError && <p id="add-error">追加できませんでした({add.variables}): {add.error.message}</p>}
{toggle.isError && <p id="toggle-error">{toggle.error.message}</p>}
<form onSubmit={(e) => { e.preventDefault(); const f = new FormData(e.currentTarget); add.mutate(String(f.get('title'))); e.currentTarget.reset() }}>
<input name="title" placeholder="タスク名" />
<button>追加</button>
</form>
</>
)
}
export function App() {
const [show, setShow] = useState(true)
return (
<>
<button onClick={() => setShow((s) => !s)}>表示切替</button>
{show && <TodoList />}
</>
)
}
npx tsc -p .(strict: true)はエラー0件でした。if (isPending) return と if (isError) return の後では、data が Todo[] に絞り込まれるため、data.map を ?. なしで書けます。
確認用の API(vite.config.ts)
import { defineConfig, type Plugin } from 'vite'
import react from '@vitejs/plugin-react'
function fakeApi(): Plugin {
let todos = [{ id: 1, title: '備品を発注', done: false }, { id: 2, title: '会議室を予約', done: false }]
let nextId = 3
return {
name: 'fake-api',
configureServer(server) {
server.middlewares.use('/api/todos', (req, res) => {
const send = (code: number, body: unknown) => { res.statusCode = code; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)) }
let raw = ''
req.on('data', (c) => (raw += c))
req.on('end', () => setTimeout(() => {
if (req.method === 'GET') return send(200, todos)
const body = raw ? JSON.parse(raw) : {}
if (req.method === 'POST') {
if (body.title === 'fail') return send(500, { message: 'サーバーエラー' })
const t = { id: nextId++, title: body.title, done: false }; todos = [...todos, t]; return send(201, t)
}
if (req.method === 'PATCH') {
if (body.id === 2) return send(500, { message: '更新できません' }) // 失敗の確認用
todos = todos.map((t) => (t.id === body.id ? { ...t, done: !t.done } : t)); return send(200, {})
}
send(405, {})
}, 300))
})
},
}
}
export default defineConfig({ plugins: [react(), fakeApi()] })
useQuery の戻り値(状態の読み方)
| プロパティ | 意味 | 使いどころ |
|---|---|---|
data |
取得結果 | 成功後に表示 |
status |
'pending' / 'error' / 'success' |
データがあるかどうか |
fetchStatus |
'fetching' / 'paused' / 'idle' |
通信中かどうか |
isPending |
まだデータがない | 初回の「読み込み中」 |
isLoading |
isPending && isFetching |
初回の通信中だけ(enabled: false で待機中は false) |
isFetching |
通信中(再取得も含む) | 右上の小さなスピナー |
isError / error |
失敗とその内容 | エラー表示 |
refetch() |
手動で取り直す | 「再読み込み」ボタン |
status は「データの有無」、fetchStatus は「通信しているか」を表す別の軸です。 キャッシュを表示しながら裏で再取得している状態は status: 'success' かつ fetchStatus: 'fetching' です。
useMutation と invalidateQueries
| オプション/戻り値 | 呼ばれるタイミング | 主な用途 |
|---|---|---|
mutationFn |
mutate(値) を呼んだとき |
API を呼ぶ |
onMutate |
mutationFn の直前 | 楽観的更新、前の値を返す |
onSuccess |
成功時 | 結果をキャッシュに入れる |
onError |
失敗時 | ロールバック、通知 |
onSettled |
成功・失敗どちらでも最後 | invalidateQueries |
variables |
送信中・送信後の引数 | 仮表示、エラー文 |
isPending / isError / isSuccess |
状態 | ボタンの無効化など |
reset() |
状態をリセット | エラー表示を消す |
invalidateQueries は「このキーのキャッシュは更新が必要になった」と印を付け、画面に表示中のクエリをすぐ再取得します。 部分一致なので、['todos'] を無効化すると ['todos', 1] や ['todos', { done: true }] もまとめて対象になります。完全一致にしたいときは exact: true を付けます。
追加:variables で仮表示する(Playwright の結果)
「資料を印刷」を追加したときの画面とリクエストの回数です。
送信直後 仮表示 => 資料を印刷
完了後 一覧 => 備品を発注 / 会議室を予約 / 資料を印刷
リクエスト GET=2 POST=1
送信直後に半透明の行が表示され、POST が終わると invalidateQueries による GET(2回目)で正式な一覧に置き換わりました。 失敗時は variables をエラー文に使えます。
エラー => 追加できませんでした(fail): サーバーエラー
variables 方式はキャッシュを書き換えないので、ロールバックの処理が要りません。 同じデータを1か所でしか表示しないなら、こちらが簡単です。
楽観的更新:onMutate で書き換え、onError で戻す
複数の画面で同じ一覧を表示している場合は、キャッシュ自体を先に書き換えます。手順は4つです。
| 手順 | コード | 理由 |
|---|---|---|
| 1. 進行中の取得を止める | await queryClient.cancelQueries({ queryKey }) |
前の応答で上書きされるのを防ぐ |
| 2. 今の値を保存 | const previous = getQueryData(queryKey) |
戻すため |
| 3. 先に書き換える | setQueryData(queryKey, 新しい値) |
すぐ画面に反映 |
| 4. 失敗したら戻す・最後に取り直す | onError で setQueryData(previous)、onSettled で invalidate |
サーバーと一致させる |
完了の切り替えで、成功する行(備品を発注)と、API が 500 を返す行(会議室を予約)を順にクリックした結果です。
成功する更新 クリック直後 checked => true
失敗する更新 クリック直後 checked => true
エラー直後 => 更新できません / 会議室を予約 false
再取得後 => 備品を発注 true / 会議室を予約 false
リクエスト GET=3 PATCH=2
失敗する行もクリック直後はチェックが付き、500 が返ると onError で元に戻りました。 setQueryData は値を「置き換える」ので、元の配列を直接書き換えず map で新しい配列を返すのがポイントです。
オプション一覧表(useQuery)
| オプション | 既定値 | 意味 | よく使う値 |
|---|---|---|---|
queryKey |
必須 | キャッシュのキー(配列) | ['todos', { page, done }] |
queryFn |
必須 | データを返す関数(Promise) | ({ signal }) => fetch(url, { signal }) |
staleTime |
0 |
新しいとみなす時間(ms) | 一覧 30_000、マスタ Infinity |
gcTime |
300000(5分) |
使われなくなったキャッシュを消すまでの時間 | 既定のままが多い |
retry |
3(ブラウザ) |
失敗時の再試行回数 | false、(n, err) => n < 2 |
refetchOnWindowFocus |
true |
タブに戻ったら再取得(stale のとき) | 編集画面では false |
enabled |
true |
false で自動取得しない | !!userId |
select |
― | 表示用に加工 | (d) => d.filter((t) => !t.done) |
placeholderData |
― | 取得完了までの仮の値 | keepPreviousData |
refetchInterval |
false |
一定間隔で再取得 | 10_000 |
throwOnError |
false |
エラーを Error Boundary へ | true |
queryKey には queryFn が使う変数をすべて入れます。 ['todos'] のまま page を変えても同じキャッシュを見てしまうので、['todos', page] にすると page ごとに別のキャッシュになります。
staleTime と gcTime の違い(実測ログ)
staleTime は「再取得しなくてよい期間」、gcTime は「誰も使っていないキャッシュを残しておく期間」です。 名前が似ていますが、測っている時間の起点が違います。
| staleTime | gcTime | |
|---|---|---|
| 数え始め | データを取得した時刻 | そのクエリを使う画面がなくなった時刻 |
| 期限が切れると | stale(更新が必要)扱い → 次のマウント・フォーカスで再取得 | キャッシュから削除 → 次は読み込み中から |
| 既定値 | 0(取得直後から stale) | 5分 |
@tanstack/query-core 5.104.0 を Node.js で直接動かし、staleTime: 1000, gcTime: 2000 で時刻を記録しました。
// cache-log.mjs(node cache-log.mjs で実行)
import { QueryClient, QueryObserver } from '@tanstack/query-core'
const t0 = Date.now()
const log = (...a) => console.log(`${String(Date.now() - t0).padStart(5)}ms`, ...a)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
let calls = 0
const queryFn = async () => { calls++; log(`queryFn 実行 (${calls}回目)`); return ['牛乳を買う', '備品を発注'] }
const qc = new QueryClient({ defaultOptions: { queries: { staleTime: 1000, gcTime: 2000 } } })
const mount = (label) => {
const obs = new QueryObserver(qc, { queryKey: ['todos'], queryFn })
const unsub = obs.subscribe(() => {})
const r = obs.getCurrentResult()
log(`${label}: status=${r.status} isStale=${r.isStale} data=${r.data ? 'あり' : 'なし'}`)
return unsub
}
const cache = (label) => {
const q = qc.getQueryCache().find({ queryKey: ['todos'] })
log(`${label}: キャッシュ=${q ? 'あり' : 'なし'}`)
}
const u1 = mount('A をマウント'); await sleep(100)
await sleep(400); const u2 = mount('B をマウント(500ms・fresh)')
await sleep(1000); const u3 = mount('C をマウント(1500ms・stale)'); await sleep(100)
u1(); u2(); u3(); log('全部アンマウント(ここから gcTime 2000ms のカウント開始)')
await sleep(1000); cache('1秒後')
await sleep(1200); cache('2.2秒後')
log(`queryFn 合計 ${calls} 回`)
5ms queryFn 実行 (1回目)
14ms A をマウント: status=pending isStale=true data=なし
516ms B をマウント(500ms・fresh): status=success isStale=false data=あり
1520ms queryFn 実行 (2回目)
1520ms C をマウント(1500ms・stale): status=success isStale=true data=あり
1621ms 全部アンマウント(ここから gcTime 2000ms のカウント開始)
2623ms 1秒後: キャッシュ=あり
3824ms 2.2秒後: キャッシュ=なし
3824ms queryFn 合計 2 回
| 時刻 | 起きたこと | 理由 |
|---|---|---|
| 516ms | B は再取得なしでデータあり | staleTime 1000ms 以内で fresh |
| 1520ms | C のマウントで再取得 | 1000ms を過ぎて stale。キャッシュを表示しつつ裏で取得 |
| 2623ms | キャッシュは残っている | アンマウントから 1 秒、gcTime 2 秒未満 |
| 3824ms | キャッシュが消えた | アンマウントから 2.2 秒で gcTime を超えた |
stale になってもキャッシュはすぐ消えず、画面にはキャッシュを出しながら裏で取り直します。 これが「戻るボタンで一瞬で表示される」理由です。
ブラウザでのリクエスト回数(Playwright)
完成コードを staleTime 0 と 5000 で起動し、/api/todos への GET を数えました(タブのフォーカスは visibilitychange イベントで再現)。
[stale=0] 初回表示 GET=1
[stale=0] アンマウント→再マウント GET=2
[stale=0] タブに戻る(フォーカス) GET=3
[stale=5000] 初回表示 GET=1
[stale=5000] アンマウント→再マウント GET=1
[stale=5000] タブに戻る(フォーカス) GET=1
| 操作 | staleTime 0(既定) | staleTime 5000 |
|---|---|---|
| 初回表示 | 1 | 1 |
| 画面を閉じて開き直す | +1 | +0 |
| 別タブから戻る | +1 | +0 |
「リクエストが多い」と感じたら、まず staleTime を見直します。 一覧なら数十秒、ほとんど変わらないマスタなら Infinity にすると、画面遷移やタブ切り替えのたびの再取得がなくなります。
v4 → v5 の変更点
| v4 | v5 | 直し方 |
|---|---|---|
useQuery(key, fn, options) |
useQuery({ queryKey, queryFn, ...options }) |
オブジェクト引数だけに |
cacheTime |
gcTime |
名前を変える |
isLoading(データなし) |
isPending |
初回判定は isPending |
status: 'loading' |
status: 'pending' |
比較文字列を変える |
keepPreviousData: true |
placeholderData: keepPreviousData |
関数を import |
useQuery の onSuccess / onError / onSettled |
削除 | useEffect か QueryCache のコールバックへ |
useErrorBoundary |
throwOnError |
名前を変える |
suspense: true |
useSuspenseQuery |
専用フックへ |
useInfiniteQuery の pageParam 既定値 |
initialPageParam 必須 |
初期値を指定 |
<Hydrate> |
<HydrationBoundary> |
名前を変える |
| React 16.8 以上 | React 18 以上 | React を上げる |
useQuery の onSuccess などは v5 で削除されました(詳しい経緯は公式の移行ガイドで確認できます)。エラー通知を1か所でまとめたいときは、次のように QueryCache に書きます。
import { QueryCache, QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => console.error(`取得に失敗: ${JSON.stringify(query.queryKey)}`, error.message),
}),
})
useQueries で複数を並列に取る
件数が実行時に決まるとき(選んだ ID ごとに取得するなど)は useQueries を使います。
import { useQueries } from '@tanstack/react-query'
function TaskDetails({ ids }: { ids: number[] }) {
const results = useQueries({
queries: ids.map((id) => ({
queryKey: ['todo', id],
queryFn: () => fetch(`/api/todos/${id}`).then((r) => r.json()),
})),
combine: (rs) => ({ data: rs.map((r) => r.data), pending: rs.some((r) => r.isPending) }),
})
if (results.pending) return <p>読み込み中…</p>
return <ul>{results.data.map((t, i) => <li key={ids[i]}>{t.title}</li>)}</ul>
}
combine で結果を1つにまとめると、使う側のコードが短くなります。 件数が固定なら useQuery を並べて書いても同じです。このコードは tsc の型チェック(エラー0件)まで確認しています。ブラウザでの動作は未実行のため、公式ドキュメントもあわせて確認してください。
useInfiniteQuery で無限スクロール
import { useInfiniteQuery } from '@tanstack/react-query'
type Page = { items: string[]; nextCursor: number | null }
function ArticleList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['articles'],
queryFn: async ({ pageParam }): Promise<Page> => (await fetch(`/api/articles?cursor=${pageParam}`)).json(),
initialPageParam: 0, // v5 で必須
getNextPageParam: (last) => last.nextCursor ?? undefined, // undefined で「次はない」
})
return (
<>
{data?.pages.flatMap((p) => p.items).map((t) => <p key={t}>{t}</p>)}
<button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}>
{hasNextPage ? 'もっと見る' : 'これで全部です'}
</button>
</>
)
}
同じ仕組みを query-core の InfiniteQueryObserver で動かし、7件を3件ずつ取得した結果です。
fetch cursor=0
pages=1 items=記事1,記事2,記事3 hasNextPage=true
fetch cursor=3
pages=2 items=記事1,記事2,記事3,記事4,記事5,記事6 hasNextPage=true
fetch cursor=6
pages=3 items=記事1,記事2,記事3,記事4,記事5,記事6,記事7 hasNextPage=false
data は pages の配列で届くので、表示するときは flatMap で平らにします。 getNextPageParam が undefined(または null)を返すと hasNextPage が false になります。スクロールで自動読み込みにするなら、ボタンの代わりに IntersectionObserver で最後の要素を監視して fetchNextPage を呼びます。
Next.js App Router で prefetch と HydrationBoundary
サーバーコンポーネントで prefetchQuery し、dehydrate したキャッシュを HydrationBoundary でクライアントに渡すと、最初の表示でデータが入った HTML になり、ブラウザ側で同じ取得をし直さずに済みます。 以下は公式ドキュメント(Advanced Server Rendering)の構成に沿ったコードです。tsc の型チェックは通していますが、Next.js 上での実行はこの記事では行っていないため、細部は公式ドキュメントで確認してください。
// app/get-query-client.ts
import { QueryClient, isServer } from '@tanstack/react-query'
function makeQueryClient() {
return new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000 } } }) // SSR 直後の再取得を防ぐ
}
let browserQueryClient: QueryClient | undefined
export function getQueryClient() {
if (isServer) return makeQueryClient() // サーバーはリクエストごとに新しく
return (browserQueryClient ??= makeQueryClient()) // ブラウザは1つを使い回す
}
// app/providers.tsx
'use client'
import { QueryClientProvider } from '@tanstack/react-query'
import { getQueryClient } from './get-query-client'
export function Providers({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={getQueryClient()}>{children}</QueryClientProvider>
}
// app/todos/page.tsx(サーバーコンポーネント)
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getQueryClient } from '../get-query-client'
import { todosQuery } from './queries'
import { TodoList } from './todo-list' // 'use client' で useQuery(todosQuery) を呼ぶ
export default async function Page() {
const queryClient = getQueryClient()
await queryClient.prefetchQuery(todosQuery)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<TodoList />
</HydrationBoundary>
)
}
HydrationBoundary の中身である dehydrate → hydrate の流れは、query-core で直接確かめました。サーバー役の QueryClient で prefetch し、JSON にして別の QueryClient に hydrate しています。
dehydrate した queryKey => ["todos"]
ブラウザ側 status=success data=備品を発注 serverCalls=1 clientCalls=0
ブラウザ側の queryFn は一度も呼ばれず(clientCalls=0)、サーバーで取ったデータがそのまま使われました。 ブラウザ側の staleTime を 0 のままにすると、hydrate 直後に stale と判定されて再取得が走るため、SSR では staleTime を 0 より大きくするのが公式の推奨です。
よくあるつまずきと直し方
| 症状 | 原因 | 直し方 |
|---|---|---|
| 404 や 500 なのに isError にならない | fetch は HTTP エラーで例外を出さない | if (!res.ok) throw new Error(...) |
| page を変えても表示が変わらない | queryKey に page が入っていない | ['todos', page] |
| 登録したのに一覧が更新されない | invalidate していない | onSettled で invalidateQueries |
| リクエストが多い | staleTime 0 で、マウント・フォーカスのたびに再取得 | staleTime を設定 |
| 楽観的更新が前の値で上書きされる | 進行中の取得が後から返ってきた | onMutate で cancelQueries |
useQuery(['todos'], fn) が型エラー |
v5 はオブジェクト引数だけ | useQuery({ queryKey, queryFn }) |
| キャッシュが毎回空になる | コンポーネント内で new QueryClient() |
外で作るか useState で保持 |
よくある質問
Q. useEffect でデータを取るのと何が違いますか? A. キャッシュ、重複リクエストの統合、再取得、エラー時の再試行、読み込み状態の管理を TanStack Query が受け持ちます。同じ queryKey を複数の部品で使っても、リクエストは1回にまとめられます。
Q. staleTime はいくつにすればよいですか?
A. 「何秒前のデータまで表示してよいか」で決めます。タスク一覧なら 30 秒〜1 分、ほとんど変わらない選択肢のマスタなら Infinity が目安です。実測でも staleTime 5000 で画面の開き直しとフォーカス時の GET が0回になりました。
Q. 楽観的更新は variables 方式と onMutate 方式のどちらがよいですか? A. 表示箇所が1か所なら variables 方式、同じデータを複数の画面や部品で表示しているなら onMutate 方式が向いています。
Q. グローバルな状態管理(Zustand など)は不要になりますか? A. API から取るデータは TanStack Query に任せ、モーダルの開閉やフォームの途中入力などの画面状態は useState や他のライブラリで持つ、という分担が一般的です。
まとめ
- v5 は
useQuery({ queryKey, queryFn })のオブジェクト引数だけ。初回判定は isPending - queryKey には queryFn が使う変数をすべて入れる
- 登録・更新の後は onSettled で invalidateQueries。部分一致でまとめて再取得
- 楽観的更新は variables 方式が簡単、複数箇所に出すなら onMutate+onError で戻す
- staleTime は再取得しない期間、gcTime は未使用キャッシュを残す期間。実測で起点の違いを確認
- リクエストが多いときは staleTime を見直す(実測で再マウント・フォーカス時の GET が0回に)
- Next.js では prefetchQuery → dehydrate → HydrationBoundary、ブラウザ側 staleTime は0より大きく
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「TanStack Query v5の使い方|useQuery・useMutation」
株式会社RJCでは、TanStack Query を使ったデータ取得とキャッシュの設計を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。