メインコンテンツまでスキップ

データフェッチング(TanStack Query)

この章では、サーバー状態管理のデファクトスタンダードである「TanStack Query」を学びます。

この章で学ぶこと

  • TanStack Queryの基本(useQuery、useMutation)
  • queryKeyによるキャッシュ管理
  • キャッシュ戦略(staleTime、gcTime)
  • 楽観的更新とカスタムフック化
備考

この章では03章で作成したプロジェクトを使用します。npm run devで開発サーバーを起動し、コードを書きながら学習を進めましょう。

TanStack Queryとは

TanStack Query(旧React Query)は、サーバーからのデータ取得・キャッシュ・同期を管理するライブラリです。

備考

11章で学んだuseEffectでもデータフェッチングは可能ですが、TanStack Queryを使う主な理由は以下の通りです:

  • ボイラープレート削減: loading/error/dataの状態管理が不要
  • 自動キャッシュ: 同じデータへの重複リクエストを防止
  • バックグラウンド更新: 画面フォーカス時に自動で最新化
  • 楽観的更新: UIを先に更新してUXを向上
サーバー状態とクライアント状態

React開発では「状態」を2種類に分けて考えると整理しやすくなります。

種類管理ツール
クライアント状態モーダルの開閉、フォーム入力、テーマ設定useState, Zustand
サーバー状態ユーザー一覧、投稿データ、API応答TanStack Query

サーバー状態は「自分が所有していないデータ」であり、他のユーザーによって変更される可能性があります。TanStack Queryはこの特性に最適化されています。

主な機能

  • 自動キャッシュ
  • バックグラウンド更新
  • 重複リクエストの排除
  • ローディング・エラー状態の管理
  • ページネーション・無限スクロール対応

セットアップ

npm install @tanstack/react-query
備考

本書ではTanStack Query v5(執筆時点 5.101.0)を使用しています。メジャーバージョンが異なる場合、APIが変更されている可能性があります。

プロバイダーの設定

// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient()

createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
)

基本的な使い方

useQuery(データ取得)

import { useQuery } from '@tanstack/react-query'

type User = {
id: number
name: string
email: string
}

function UserProfile({ userId }: { userId: number }) {
const { data, isPending, isError, error } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) throw new Error('Failed to fetch')
return response.json() as Promise<User>
}
})

if (isPending) return <p>読み込み中...</p>
if (isError) return <p>エラー: {error.message}</p>

return (
<div>
<h2>{data?.name}</h2>
<p>{data?.email}</p>
</div>
)
}

試してみよう: useQueryの実装

JSONPlaceholderのAPI(https://jsonplaceholder.typicode.com/users)からユーザー一覧を取得し、ローディング・エラー状態を適切に表示してみましょう。

ヒント
  1. useQueryqueryKeyqueryFnを設定
  2. isPendingisErrordataで状態に応じた表示を切り替え
  3. エラーハンドリングを忘れずに
回答と解説
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'

const queryClient = new QueryClient()

createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>
)
// src/App.tsx
import { useQuery } from '@tanstack/react-query'

type User = {
id: number
name: string
email: string
phone: string
website: string
}

function App() {
const { data, isPending, isError, error } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/users')
if (!response.ok) {
throw new Error('ユーザー一覧の取得に失敗しました')
}
return response.json() as Promise<User[]>
}
})

if (isPending) {
return (
<div className="p-8">
<p className="text-gray-500">読み込み中...</p>
</div>
)
}

if (isError) {
return (
<div className="p-8">
<p className="text-red-500">エラー: {error.message}</p>
</div>
)
}

return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">ユーザー一覧</h1>
<ul className="space-y-2">
{data?.map((user) => (
<li key={user.id} className="p-4 bg-gray-100 rounded">
<p className="font-bold">{user.name}</p>
<p className="text-gray-600 text-sm">{user.email}</p>
</li>
))}
</ul>
</div>
)
}

export default App

useQueryqueryKeyでキャッシュを識別し、queryFnでデータを取得します。isPendingisErrordataで状態に応じたUIを表示します。

状態フラグの使い分け(isPending / isLoading / isFetching)

TanStack Query v5 では、クエリの状態を表すフラグが複数あります。用途が似ているため混乱しがちですが、意味は明確に異なります。

フラグ意味使いどころ
isPendingdata がまだ存在しない(最初のフェッチ中、または失敗後)データ取得前のスケルトン/スピナー表示全般
isLoading初回の取得中のみ true(isPending && isFetching初回だけスピナーを出したい場合
isFetchingバックグラウンドでの再取得中も含めて true再取得中のインジケーター表示
isErrorエラーが発生しているエラーメッセージ表示
const { data, isPending, isFetching, isError, error } = useQuery({...})

if (isPending) return <Spinner /> // 初回表示のとき
if (isError) return <ErrorMessage error={error} />
return (
<div>
{isFetching && <span>更新中...</span>} {/* 再取得中にだけ出る */}
<UserProfile user={data} />
</div>
)
備考

v5 より前のバージョンでは isLoading が「データがまだない状態」を表していましたが、v5 では同じ意味を isPending が担うように整理されました。isLoading 自体は「初回ロードのみ true」として残っていますが、特別な事情がなければ isPending を使うのが推奨です。

useSuspenseQuery(Suspense 統合)

React 18 以降、<Suspense> がデータ取得のローディング境界として正式にサポートされました。TanStack Query では useSuspenseQuery を使うと、ローディング表示とエラー処理を親コンポーネントに委譲できます。

コード例の <ErrorBoundary> には、コミュニティ標準の react-error-boundary パッケージを使います。

npm install react-error-boundary
import { useSuspenseQuery } from '@tanstack/react-query'
import { Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'

function UserProfile({ userId }: { userId: number }) {
// data は必ず存在する(loading/error 状態は上位で扱われる)
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
})

return <h2>{data.name}</h2>
}

function App() {
return (
<ErrorBoundary fallback={<p>エラーが発生しました</p>}>
<Suspense fallback={<p>読み込み中...</p>}>
<UserProfile userId={1} />
</Suspense>
</ErrorBoundary>
)
}

useSuspenseQuery を使う利点は次の通りです。

  • コンポーネント内で isPending / isError をチェックする必要がなくなり、data が必ず存在する前提で書ける
  • 複数のクエリをまとめて <Suspense> で待てるため、「全部揃ってから表示」が簡単に作れる
  • <ErrorBoundary> と組み合わせることで、エラー処理を宣言的に書ける
備考

useSuspenseQuery には enabled オプションがありません(クエリを条件で無効化できない)。ルーティングによってクエリ自体が実行されない設計にするか、enabled が必要な場合は通常の useQuery を使います。

React 19 の use(Promise) との違い

React 19 では、任意の Promise を受け取ってその結果を Suspense 境界で解決する use hook が利用可能になりました。一見似ていますが、役割は異なります。

観点use(Promise)useSuspenseQuery
キャッシュなし(呼び出しごとに Promise が評価される)あり(queryKey で共有)
再取得・無効化手動で Promise を差し替える必要があるinvalidateQueries で宣言的に再取得
バックグラウンド更新なしstaleTime / refetchOnWindowFocus で自動
用途Server Components から受け渡された Promise の解決などSPA でのサーバーデータ管理全般

SPA でサーバー状態を扱うなら、キャッシュと再取得戦略を持つ useSuspenseQuery が第一候補です。use(Promise) は、親コンポーネントや Server Components から Promise を props で受け取る設計のときに真価を発揮します。

queryKeyの設計

// シンプルなキー
queryKey: ['todos']

// パラメータ付き
queryKey: ['todo', todoId]

// フィルター条件付き
queryKey: ['todos', { status: 'completed', page: 1 }]
警告

queryKeyはキャッシュの識別子です。同じキーを持つクエリは同じデータを共有します。パラメータが変わる場合は必ずキーに含めてください。

// NG: userIdが変わってもキャッシュが共有される
queryKey: ['user']

// OK: userIdごとに別のキャッシュになる
queryKey: ['user', userId]

useMutation(データ更新)

import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'

function CreateTodo() {
const queryClient = useQueryClient()
const [text, setText] = useState('')

const mutation = useMutation({
mutationFn: async (newTodo: { text: string }) => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo)
})
return response.json()
},
onSuccess: () => {
// キャッシュを無効化して再取得
queryClient.invalidateQueries({ queryKey: ['todos'] })
setText('')
}
})

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
mutation.mutate({ text })
}

return (
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
disabled={mutation.isPending}
/>
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? '追加中...' : '追加'}
</button>
{mutation.isError && <p>エラー: {mutation.error.message}</p>}
</form>
)
}

キャッシュ戦略

staleTime と gcTime

const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
staleTime: 5 * 60 * 1000, // 5分間はfresh(再取得しない)
gcTime: 30 * 60 * 1000 // 30分間キャッシュを保持
})
オプション説明
staleTimeデータがfreshとみなされる時間(デフォルト: 0)
gcTime未使用データがキャッシュに残る時間(デフォルト: 5分)

refetchの制御

const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
refetchOnWindowFocus: false, // ウィンドウフォーカス時の再取得を無効化
refetchOnMount: false, // マウント時の再取得を無効化
refetchInterval: 30000 // 30秒ごとに自動再取得
})

試してみよう: キャッシュ戦略の設定

staleTimeを5分に設定し、DevToolsでキャッシュの動作を確認してみましょう。

ヒント
  1. useQueryのオプションにstaleTime: 5 * 60 * 1000を追加
  2. DevToolsで「fresh」「stale」の状態変化を確認
  3. console.logでAPIコールのタイミングを確認
回答と解説
// src/hooks/useUsers.ts
import { useQuery } from '@tanstack/react-query'

type User = {
id: number
name: string
email: string
phone: string
website: string
}

export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
console.log('Fetching users...') // APIコールを確認
const response = await fetch('https://jsonplaceholder.typicode.com/users')
if (!response.ok) {
throw new Error('ユーザー一覧の取得に失敗しました')
}
return response.json() as Promise<User[]>
},
staleTime: 5 * 60 * 1000, // 5分間はfresh
gcTime: 30 * 60 * 1000 // 30分間キャッシュを保持
})
}
// src/main.tsx(DevToolsを追加)
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import App from './App'
import './index.css'

const queryClient = new QueryClient()

createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>
)

staleTimeを設定すると、その時間内は「fresh」状態となり、再フェッチが行われません。DevToolsを開くと、クエリの状態(fresh/stale)やキャッシュの内容を確認できます。5分経過後に画面をフォーカスすると、自動的にバックグラウンド更新が実行されます。console.logを入れておくと、実際にAPIコールが発生したタイミングを確認できます。

実践的なパターン

ローディングとエラーの統一処理

type QueryResult<T> = {
data: T | undefined
isPending: boolean
isError: boolean
error: Error | null
}

function QueryWrapper<T>({
query,
children
}: {
query: QueryResult<T>
children: (data: T) => React.ReactNode
}) {
if (query.isPending) return <LoadingSpinner />
if (query.isError) return <ErrorMessage error={query.error} />
if (!query.data) return null

return <>{children(query.data)}</>
}

// 使用例
function UserList() {
const query = useQuery({ queryKey: ['users'], queryFn: fetchUsers })

return (
<QueryWrapper query={query}>
{(users) => (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</QueryWrapper>
)
}

依存クエリ

function UserPosts({ userId }: { userId: number }) {
// 最初にユーザーを取得
const userQuery = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId)
})

// ユーザーが取得できたら投稿を取得
const postsQuery = useQuery({
queryKey: ['posts', userId],
queryFn: () => fetchPostsByUser(userId),
enabled: !!userQuery.data // userQueryが成功してから実行
})

// ...
}

楽観的更新

const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// 進行中のクエリをキャンセル
await queryClient.cancelQueries({ queryKey: ['todos'] })

// 以前の値を保存
const previousTodos = queryClient.getQueryData(['todos'])

// 楽観的に更新
queryClient.setQueryData(['todos'], (old: Todo[]) =>
old.map((todo) =>
todo.id === newTodo.id ? newTodo : todo
)
)

return { previousTodos }
},
onError: (err, newTodo, context) => {
// エラー時はロールバック
queryClient.setQueryData(['todos'], context?.previousTodos)
},
onSettled: () => {
// 完了後にキャッシュを無効化
queryClient.invalidateQueries({ queryKey: ['todos'] })
}
})

DevTools

npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'

function App() {
return (
<QueryClientProvider client={queryClient}>
<MyApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}

カスタムフックへの抽出

15章で学んだカスタムフックのパターンを活用して、クエリロジックを再利用可能にしましょう。

function useUser(userId: number) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 5 * 60 * 1000
})
}

function useUpdateUser() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: updateUser,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['user', data.id] })
}
})
}

// 使用例
function UserProfile({ userId }: { userId: number }) {
const { data: user, isPending } = useUser(userId)
const updateMutation = useUpdateUser()

// ...
}

試してみよう: カスタムフック化

ユーザー取得のクエリをuseUsersというカスタムフックに抽出してみましょう。

ヒント
  1. useQueryを呼び出す部分を別の関数に切り出し
  2. useで始まる名前を付ける
  3. 型定義もフック内にまとめる
回答と解説
// src/hooks/useUsers.ts
import { useQuery } from '@tanstack/react-query'

type User = {
id: number
name: string
email: string
phone: string
website: string
}

export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/users')
if (!response.ok) {
throw new Error('ユーザー一覧の取得に失敗しました')
}
return response.json() as Promise<User[]>
}
})
}

// 特定のユーザーを取得するフック
export function useUser(userId: number) {
return useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
if (!response.ok) {
throw new Error('ユーザーの取得に失敗しました')
}
return response.json() as Promise<User>
},
enabled: userId > 0 // userIdが有効な場合のみ実行
})
}
// src/App.tsx
import { useUsers } from './hooks/useUsers'

function App() {
const { data: users, isPending, isError, error } = useUsers()

if (isPending) {
return <div className="p-8"><p>読み込み中...</p></div>
}

if (isError) {
return <div className="p-8"><p className="text-red-500">エラー: {error.message}</p></div>
}

return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">ユーザー一覧</h1>
<ul className="space-y-2">
{users?.map((user) => (
<li key={user.id} className="p-4 bg-gray-100 rounded">
<p className="font-bold">{user.name}</p>
<p className="text-gray-600 text-sm">{user.email}</p>
</li>
))}
</ul>
</div>
)
}

export default App

カスタムフックに抽出することで、クエリロジックを複数のコンポーネントで再利用できます。型定義もフック内にまとめることで、使用側はシンプルになります。

まとめ

  • TanStack Queryはサーバー状態管理のデファクトスタンダード
  • useQueryでデータ取得、useMutationで更新
  • queryKeyでキャッシュを識別
  • staleTimegcTimeでキャッシュ戦略を制御
  • 楽観的更新でUXを向上
  • カスタムフックに抽出して再利用

次の章では、React Routerによるルーティングを学びます。

次に読む