
はじめに
こんにちは。ラクスル株式会社 エンジニアの小谷です。
この記事は
の 8 日目です。
最近、ReactでSPAを構築する中でTanStack Query v5を導入しました。特に useSuspenseQuery と React の Suspense を組み合わせたデータフェッチが非常に開発者体験が良かったので、その知見を共有したいと思います。
この記事では、バックエンドからのデータ取得方法を3つのアプローチで比較しながら、useSuspenseQuery の利点と注意点について解説します。
バックエンドからのデータ取得方法
Reactでバックエンドからデータを取得する方法として、以下の3つのアプローチを順に見ていきます。
useEffectを使う方法- TanStack Query の
useQueryを使う方法 - TanStack Query の
useSuspenseQuery+ ReactのSuspense を使う方法
それぞれのアプローチを、同じ「ユーザー一覧を取得して表示する」という例で比較してみます。
1. useEffectを使う方法
まずは最も基本的な方法として、useEffect と fetch を組み合わせたアプローチです。
データフェッチライブラリを使わずAIへ実装させると大体以下のようなコードになり、コンポーネント間でローディングやエラーの状態管理で重複した実装が多く発生します。
import { useEffect, useState } from 'react'; type User = { id: number; name: string; email: string; }; function UserList() { const [users, setUsers] = useState<User[]>([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState<Error | null>(null); useEffect(() => { const fetchUsers = async () => { try { setIsLoading(true); const response = await fetch('/api/users'); if (!response.ok) { throw new Error('Failed to fetch users'); } const data = await response.json(); setUsers(data); } catch (e) { setError(e instanceof Error ? e : new Error('Unknown error')); } finally { setIsLoading(false); } }; fetchUsers(); }, []); 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> ); }
この方法には以下の問題点があります。
- 状態管理が煩雑:
isLoading,error,dataの3つの状態を手動で管理する必要がある - ボイラープレートが多い: 毎回同じようなtry-catch-finallyのパターンを書く必要がある
- キャッシュがない: コンポーネントのアンマウント・再マウントのたびにデータを再取得する
- 重複リクエストの制御が難しい: 同じデータを複数のコンポーネントで使う場合、それぞれでAPIリクエストが発生する
2. TanStack QueryのuseQueryを使う方法
TanStack Queryの useQuery を使うと、useEffectでの問題が大幅に改善され、以下のような実装になります。
import { useQuery } from '@tanstack/react-query'; type User = { id: number; name: string; email: string; }; const fetchUsers = async (): Promise<User[]> => { const response = await fetch('/api/users'); if (!response.ok) { throw new Error('Failed to fetch users'); } return response.json(); }; function UserList() { const { data, isLoading, error } = useQuery({ queryKey: ['users'], queryFn: fetchUsers, }); if (isLoading) { return <div>Loading...</div>; } if (error) { return <div>Error: {error.message}</div>; } return ( <ul> {data?.map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul> ); }
useQuery を使うことで、以下のメリットが得られます。
- 状態管理の簡素化:
isLoading,error,dataが自動的に管理される - キャッシュ機能: 同じ
queryKeyに対するリクエストはキャッシュされ、重複リクエストが防止される - バックグラウンド更新: フォーカス時やネットワーク復帰時に自動的にデータを再取得
- リトライ機能: 失敗時に自動リトライ。 リトライ数はuseQueryのretryオプションで設定可能です
しかし、コンポーネント内でまだ isLoading と error の分岐処理を書く必要があります。また、型定義を見ると data は User[] | undefined となっており、データを使う際は data?.map のようにオプショナルチェーンが必要です。
3. useSuspenseQuery + React Suspenseを使う方法
useSuspenseQuery と React の Suspense を組み合わせると、コンポーネントがさらにシンプルになります。
import { useSuspenseQuery } from '@tanstack/react-query'; import { Suspense } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; type User = { id: number; name: string; email: string; }; const fetchUsers = async (): Promise<User[]> => { const response = await fetch('/api/users'); if (!response.ok) { throw new Error('Failed to fetch users'); } return response.json(); }; // データ取得コンポーネント - loading/errorの分岐がない function UserList() { const { data } = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers, }); // data は User[] 型(User[] | undefinedではない) return ( <ul> {data.map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul> ); } // 親コンポーネントでSuspenseとErrorBoundaryをラップ function App() { return ( <ErrorBoundary fallback={<div>Something went wrong</div>}> <Suspense fallback={<div>Loading...</div>}> <UserList /> </Suspense> </ErrorBoundary> ); }
このアプローチには以下の大きなメリットがあります。
メリット1: loading/errorをコンポーネントの外に追い出せる
UserList コンポーネントの中に isLoading や error の分岐がありません。ローディング状態は親の Suspense コンポーネントの fallback で、エラー状態は ErrorBoundary でそれぞれハンドリングされます。
これにより、データを取得するコンポーネントは「データがある前提」でUIをレンダリングするコードだけを書けばよくなり、コンポーネントの責務が明確になります。
メリット2: dataが常に存在することが保証される
useQuery の場合:
const { data } = useQuery({ queryKey: ['users'], queryFn: fetchUsers }); // data の型は User[] | undefined
useSuspenseQuery の場合:
const { data } = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers }); // data の型は User[](undefinedではない)
useSuspenseQuery ではデータが取得されるまでコンポーネントがサスペンドされるため、レンダリング時には必ずデータが存在します。これにより、data?.map のようなオプショナルチェーンや、if (!data) のようなnullチェックが不要になります。
型安全性が高まり、コードがより簡潔になります。
useSuspenseQuery使用時の注意点: Request Waterfalls
useSuspenseQuery は非常に便利ですが、一つ注意すべき点があります。それは Request Waterfalls(リクエストの直列化) の問題です。
Request Waterfallsとは
Request Waterfallsとは、「あるリソースのリクエストが、別のリソースのリクエストが完了するまで開始されない」状態を指します。
例えば、以下のようなコンポーネントを考えてみましょう。
// これは問題のあるコード! function Dashboard() { const { data: users } = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers, }); const { data: posts } = useSuspenseQuery({ queryKey: ['posts'], queryFn: fetchPosts, }); const { data: comments } = useSuspenseQuery({ queryKey: ['comments'], queryFn: fetchComments, }); return ( <div> {/* users, posts, comments を使ったUI */} </div> ); }
このコードでは、3つのクエリが 直列 に実行されます。
時間 →
|-- users fetch --|
|-- posts fetch --|
|-- comments fetch --|
最初の useSuspenseQuery でコンポーネントがサスペンドされ、users のデータが取得されるまで次の useSuspenseQuery は実行されません。これが繰り返されるため、本来並列で実行できるはずのリクエストが直列化され、総待ち時間が長くなってしまいます。
解決策: useSuspenseQueriesを使う
この問題を解決するには、useSuspenseQueries を使って複数のクエリを並列実行します。
import { useSuspenseQueries } from '@tanstack/react-query'; function Dashboard() { const [ { data: users }, { data: posts }, { data: comments }, ] = useSuspenseQueries({ queries: [ { queryKey: ['users'], queryFn: fetchUsers }, { queryKey: ['posts'], queryFn: fetchPosts }, { queryKey: ['comments'], queryFn: fetchComments }, ], }); return ( <div> {/* users, posts, comments を使ったUI */} </div> ); }
useSuspenseQueries を使うと、すべてのクエリが 並列 に実行されます。
時間 → |-- users fetch --| |-- posts fetch --| |-- comments fetch --|
すべてのリクエストが同時に開始されるため、総待ち時間は最も遅いリクエストの時間のみとなり、大幅なパフォーマンス改善が期待できます。
親子コンポーネント間のWaterfall
親コンポーネントと子コンポーネントの両方がクエリを持つ場合もWaterfallが発生します。以下の例を見てみましょう。
function UserProfile({ userId }: { userId: number }) { const { data: user } = useSuspenseQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), }); return ( <div> <h1>{user.name}</h1> <UserPosts userId={userId} /> </div> ); } function UserPosts({ userId }: { userId: number }) { const { data: posts } = useSuspenseQuery({ queryKey: ['posts', userId], queryFn: () => fetchPostsByUser(userId), }); return <PostList posts={posts} />; }
この場合、UserPostsは親からuserIdを受け取っていますが、このuserIdはUserProfileがレンダリングされる時点で既に利用可能です。しかし、親のクエリが完了するまで子はレンダリングされないため、Waterfallが発生します。
時間 →
|-- fetchUser --|
|-- fetchPostsByUser --|
解決策: クエリを親にまとめる
依存関係にないクエリを親コンポーネントにまとめてuseSuspenseQueriesで並列実行します。
function UserProfile({ userId }: { userId: number }) { const [{ data: user }, { data: posts }] = useSuspenseQueries({ queries: [ { queryKey: ['user', userId], queryFn: () => fetchUser(userId) }, { queryKey: ['posts', userId], queryFn: () => fetchPostsByUser(userId) }, ], }); return ( <div> <h1>{user.name}</h1> <PostList posts={posts} /> </div> ); }
依存関係のあるWaterfall
より難しいケースとして、子のクエリが親のデータに依存する場合があります。
function UserProfile({ userId }: { userId: number }) { const { data: user } = useSuspenseQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId), }); return ( <div> <h1>{user.name}</h1> {/* user.teamId は親のクエリ結果に依存 */} <TeamInfo teamId={user.teamId} /> </div> ); } function TeamInfo({ teamId }: { teamId: number }) { const { data: team } = useSuspenseQuery({ queryKey: ['team', teamId], queryFn: () => fetchTeam(teamId), }); return <div>Team: {team.name}</div>; }
この場合、TeamInfoのクエリはuser.teamIdに依存しているため、親のクエリが完了するまで子のクエリを開始できません。これは単純にクエリをまとめるだけでは解決できません。
解決策: APIの改善
SPAにおいてこのようなDependent Waterfallを解消する最も効果的な方法は、バックエンドのAPIを改善することです。
例えば、上記の例ではfetchUserのレスポンスにチーム情報も含めるようAPIを拡張します。
// Before: 2回のAPIリクエストが必要 // GET /api/users/:id -> { id, name, teamId } // GET /api/teams/:id -> { id, name } // After: 1回のAPIリクエストで完結 // GET /api/users/:id?include=team -> { id, name, team: { id, name } } function UserProfile({ userId }: { userId: number }) { const { data: user } = useSuspenseQuery({ queryKey: ['user', userId], queryFn: () => fetchUserWithTeam(userId), // チーム情報も含めて取得 }); return ( <div> <h1>{user.name}</h1> <div>Team: {user.team.name}</div> </div> ); }
おわりに
この記事では、Reactでのデータフェッチ方法を3つのアプローチで比較しました。
| アプローチ | loading/error管理 | dataの型 | キャッシュ |
|---|---|---|---|
| useEffect | 手動 | T | null |
なし |
| useQuery | 自動(コンポーネント内で分岐) | T | undefined |
あり |
| useSuspenseQuery | 自動(Suspense/ErrorBoundary) | T |
あり |
useSuspenseQuery + Suspense の組み合わせにより、コンポーネントがシンプルになり、型安全性も向上します。ただし、複数のクエリを同じコンポーネントで使う場合は、Request Waterfallsに注意して useSuspenseQueries を使いましょう。