大量のデータを効率的に表示するページネーションは、Web アプリケーションにおいて欠かせない機能です。TanStack Query(@tanstack/react-query) でページネーションを実装する際には、useQuery ベース手法と useSuspenseQuery ベース手法 の 2 つのアプローチが存在すると考えられます。
本記事では、以下の 2 つのアプローチを比較し、それぞれの特徴と適用場面を明確にします。
アプローチ | 特徴 |
---|---|
useQuery ベース手法 |
useQuery + placeholderData + 状態管理 |
useSuspenseQuery ベース手法 |
useSuspenseQuery + useTransition + Suspense 境界 |
まずは両者の基本的な違いをざっくり比較してみます。
観点 | useQuery ベース手法 | useSuspenseQuery ベース手法 |
---|---|---|
ローディング制御 | コンポーネント内で isLoading を個別管理 |
Suspense 境界で統一的に管理 |
エラー処理 | コンポーネント内で error 状態を個別処理 |
ErrorBoundary で統一的に処理 |
コード量 | 各コンポーネントでのボイラープレート多め | コンポーネントはデータ表示に集中 |
UX 改善 |
placeholderData で前ページを表示 |
useTransition でトランジション制御 |
複数クエリ実行 | 同一コンポーネント内でも並列実行(デフォルト) | 同一コンポーネント内ではシリアル実行 ( useSuspenseQueries で並列実行可能) |
useQuery ベースのページネーション実装は、useQuery
と placeholderData
を組み合わせた手法です。
1-1. useQuery の仕組みを理解する
TanStack Query は 「キャッシュファースト」 のアプローチを採用しています。つまり、データが必要になったとき、まずキャッシュを確認し、データがなければ API を呼び出します。
実際の動作フロー
GitHub API のページネーションを例に、TanStack Query がどのように動作するかを見てみましょう:
export type Repository = {
id: number;
full_name: string;
description: string | null;
stargazers_count: number;
};
export const PAGE_SIZE = 4;
export async function fetchRepos(page: number): PromiseRepository[]> {
const response = await fetch(
`https://api.github.com/orgs/TanStack/repos?per_page=4&page=${page}`
);
if (!response.ok) {
throw new ApiError(
`Failed to fetch repositories`,
response.status,
'FETCH_REPOS_ERROR'
);
}
return response.json();
}
useQuery の基本実装
import { useQuery } from '@tanstack/react-query';
export function useRepos(page: number) {
return useQuery({
queryKey: ['repos', { page }] as const,
queryFn: () => fetchRepos(page),
staleTime: 10 * 1000,
});
}
この実装により、以下の動作が自動的に行われます:
-
初回呼び出し:
['repos', { page: 1 }]
のキーでキャッシュを確認 - キャッシュミス: データがないので API を呼び出し
- キャッシュ保存: 取得したデータをキーと紐付けて保存
- 再利用: 同じキーで再度呼び出されたときはキャッシュから返す
ページ間移動でのキャッシュ動作
以下のように異なるページを行き来する場合を考えてみましょう:
const Component: React.FC = () => {
const [page, setPage] = useState(1);
const { data, isLoading } = useRepos(page);
return (
>
div>{isLoading ? '読み込み中...' : `${data?.length}件のリポジトリ`}div>
button onClick={() => setPage(page + 1)}>次のページbutton>
button onClick={() => setPage(page - 1)}>前のページbutton>
>
);
};
1-2. placeholderData で滑らかな UX を実現する
通常のページネーションでは、ページを変更するたびに「読み込み中…」が表示され、ユーザー体験が途切れてしまいます。
placeholderData
を使うことで、新しいデータを取得している間も前のデータを表示し続けることができます。
placeholderData なしの場合
ページ変更時の動作:
- 新しいクエリキーに対してキャッシュを確認
- データがない → isLoading = true
- 画面が「読み込み中…」表示に切り替わる
- API レスポンス → data 更新、isLoading = false
const { data, isLoading } = useQuery({
queryKey: ['repos', { page }],
queryFn: () => fetchRepos(page),
});
placeholderData ありの場合
ページ変更時の動作:
- 新しいクエリキーに対してキャッシュを確認
- データがない → isLoading = true, data = 前のページのデータ
- 画面は前のデータを表示し続ける(読み込み中にならない)
- API レスポンス → data 更新、isPlaceholderData = false
const { data, isLoading, isPlaceholderData } = useQuery({
queryKey: ['repos', { page }],
queryFn: () => fetchRepos(page),
placeholderData: (previousData) => previousData,
});
利用例
import React, { useState, useCallback } from 'react';
const RepoList: React.FC = () => {
const [page, setPage] = useState(1);
const { data, isLoading, isError, error, isPlaceholderData } = useQuery
Repository[],
Error
>({
queryKey: ['repos', { page }],
queryFn: () => fetchRepos(page),
placeholderData: (previousData) => previousData,
staleTime: 10 * 1000,
});
const handlePageChange = useCallback((newPage: number) => {
setPage(newPage);
}, []);
const isLastPage = data && data.length PAGE_SIZE;
if (isLoading && !isPlaceholderData) {
return div>読み込み中...div>;
}
if (isError) {
return div>エラーが発生しました: {error?.message}div>;
}
return (
>
{}
{isPlaceholderData && span>更新中...span>}
{}
div style={{ opacity: isPlaceholderData ? 0.7 : 1 }}>
{data?.map((repo) => (
div key={repo.id}>
h3>{repo.full_name}h3>
{repo.description && p>{repo.description}p>}
span>⭐ {repo.stargazers_count.toLocaleString()}span>
div>
))}
div>
{}
>
button
onClick={() => handlePageChange(page - 1)}
disabled={page === 1 || isPlaceholderData}
>
前のページ
button>
span>
ページ {page}
{isPlaceholderData && span>(更新中)span>}
span>
button
onClick={() => handlePageChange(page + 1)}
disabled={isLastPage || isPlaceholderData}
>
次のページ
button>
>
>
);
};
export default RepoList;
placeholderData の利点
- ユーザー体験の向上: ページ遷移時に画面が真っ白にならない
- 視覚的な継続性: 前のデータが表示されるため、操作の文脈が保たれる
- パフォーマンス感の向上: 実際の読み込み時間は変わらないが、体感速度が向上
placeholderData の注意点
ソートやフィルタリングなどの条件がある場合、前回のデータを表示し続けることが適切ではない場合があります。そのような場合は、条件付きで placeholderData を使用することが重要です。
const { data, isPlaceholderData } = useQuery({
queryKey: ['repos', { page, sortBy }],
queryFn: () => fetchRepos(page, sortBy),
placeholderData: (previousData, previousQuery) => {
if (previousQuery) {
const prevKey = previousQuery.queryKey as [
'repos',
{ page: number; sortBy: string }
];
if (prevKey[1].sortBy === sortBy) {
return previousData;
}
}
return undefined;
},
});
この実装により、ソート条件が変わったときは適切にローディング状態を表示し、同じソート条件でのページ移動時のみ滑らかな遷移を実現できます。
1-3. 型安全パターンと実用実装
TanStack Query のページネーション実装において、TypeScript の型システムを最大限活用することで、実行時エラーの撲滅と開発体験の向上を実現できます。以下、プロダクション環境で使用できる包括的な実装例を示します。
型推論とクエリキー管理
クエリキーの型安全性を保証し、クエリファクトリーパターンを活用することで、大規模なアプリケーションでも保守性の高い実装が可能になります。
型安全なクエリ管理システムの実装例
import {
UseQueryOptions,
useQuery,
useQueryClient,
} from '@tanstack/react-query';
import { useCallback } from 'react';
import { Repository, fetchRepos } from '../api';
type RepositoryQueryKey = readonly ['repos', 'list', { page: number }];
export const repositoryQueries = {
all: () => ['repos'] as const,
lists: () => [...repositoryQueries.all(), 'list'] as const,
list: (filters: { page: number }) =>
[...repositoryQueries.lists(), filters] as const,
details: () => [...repositoryQueries.all(), 'detail'] as const,
detail: (id: number) => [...repositoryQueries.details(), id] as const,
};
function createRepositoryQueryOptions(
page: number
): UseQueryOptionsRepository[], Error, Repository[], RepositoryQueryKey> {
return {
queryKey: repositoryQueries.list({ page }),
queryFn: async (): PromiseRepository[]> => {
const result = await fetchRepos(page);
if (process.env.NODE_ENV === 'development') {
const isValidRepository = (obj: any): obj is Repository => {
return (
typeof obj === 'object' &&
typeof obj.id === 'number' &&
typeof obj.full_name === 'string' &&
typeof obj.stargazers_count === 'number'
);
};
if (!Array.isArray(result) || !result.every(isValidRepository)) {
throw new Error('Invalid repository data received from API');
}
}
return result;
},
staleTime: 10 * 1000,
};
}
type QueryResultT> = {
data: T | undefined;
isLoading: boolean;
isError: boolean;
error: Error | null;
};
export function useTypedRepos(page: number): QueryResultRepository[]> & {
isPlaceholderData: boolean;
prefetchNext: () => Promisevoid>;
prefetchPrevious: () => Promisevoid>;
} {
const queryClient = useQueryClient();
const query = useQuery({
...createRepositoryQueryOptions(page),
placeholderData: (previousData) => previousData,
});
const prefetchNext = useCallback(async () => {
await queryClient.prefetchQuery(createRepositoryQueryOptions(page + 1));
}, [queryClient, page]);
const prefetchPrevious = useCallback(async () => {
if (page > 1) {
await queryClient.prefetchQuery(createRepositoryQueryOptions(page - 1));
}
}, [queryClient, page]);
return {
...query,
prefetchNext,
prefetchPrevious,
isPlaceholderData: query.isPlaceholderData ?? false,
};
}
型レベルでのページネーション状態管理
ページネーション状態の変更を型安全に管理することで、意図しない状態遷移を防ぎ、バグの少ないアプリケーションを構築できます。
型安全なページネーション状態管理の実装
import { useReducer, useMemo } from 'react';
interface PaginationState {
readonly page: number;
readonly isFirstPage: boolean;
readonly isLastPage: boolean;
}
type PaginationAction =
| { type: 'NEXT_PAGE' }
| { type: 'PREVIOUS_PAGE' }
| { type: 'GO_TO_PAGE'; page: number }
| { type: 'SET_LAST_PAGE'; isLastPage: boolean };
function paginationReducer(
state: PaginationState,
action: PaginationAction
): PaginationState {
switch (action.type) {
case 'NEXT_PAGE':
if (state.isLastPage) return state;
return {
...state,
page: state.page + 1,
isFirstPage: false,
};
case 'PREVIOUS_PAGE':
if (state.isFirstPage || state.page 1) return state;
const newPage = Math.max(1, state.page - 1);
return {
...state,
page: newPage,
isFirstPage: newPage 1,
isLastPage: false,
};
case 'GO_TO_PAGE':
return {
...state,
page: Math.max(1, action.page),
isFirstPage: action.page 1,
};
case 'SET_LAST_PAGE':
return {
...state,
isLastPage: action.isLastPage,
};
default:
return state;
}
}
export function usePaginationState(initialPage: number = 1) {
const safePage = Math.max(1, initialPage);
const [state, dispatch] = useReducer(paginationReducer, {
page: safePage,
isFirstPage: safePage 1,
isLastPage: false,
});
const actions = useMemo(
() => ({
nextPage: () => dispatch({ type: 'NEXT_PAGE' }),
previousPage: () => dispatch({ type: 'PREVIOUS_PAGE' }),
goToPage: (page: number) => dispatch({ type: 'GO_TO_PAGE', page }),
setLastPage: (isLastPage: boolean) =>
dispatch({ type: 'SET_LAST_PAGE', isLastPage }),
}),
[]
);
return [state, actions] as const;
}
最適化されたコンポーネント設計
型安全性、パフォーマンス監視、メモ化を組み合わせた実用的な実装例:
プロダクション対応の実装例
import React, { useCallback, useMemo } from 'react';
import { Repository, PAGE_SIZE } from '../api';
import { useTypedRepos } from '../hooks/useTypedRepos';
import { usePaginationState } from '../hooks/usePaginationState';
type RepoListProps = {
onRepositoryClick?: (repo: Repository) => void;
};
const OptimizedRepoList: React.FCRepoListProps> = ({ onRepositoryClick }) => {
const [paginationState, paginationActions] = usePaginationState();
const { page, isFirstPage } = paginationState;
const { data, isPlaceholderData, status, prefetchNext, prefetchPrevious } =
useTypedRepos(page);
const handleNextPage = useCallback(() => {
if (!isPlaceholderData && data && data.length === PAGE_SIZE) {
paginationActions.nextPage();
prefetchNext();
}
}, [isPlaceholderData, data, paginationActions, prefetchNext]);
const handlePreviousPage = useCallback(() => {
if (!isPlaceholderData && !isFirstPage) {
paginationActions.previousPage();
prefetchPrevious();
}
}, [isPlaceholderData, isFirstPage, paginationActions, prefetchPrevious]);
const isLastPage = useMemo(() => {
const isLast = data && data.length PAGE_SIZE;
if (isLast !== paginationState.isLastPage) {
paginationActions.setLastPage(!!isLast);
}
return !!isLast;
}, [data, paginationState.isLastPage, paginationActions]);
const renderRepository = useCallback(
(repo: Repository) => (
li key={repo.id} onClick={() => onRepositoryClick?.(repo)}>
h3>{repo.full_name}h3>
p>{repo.description || 'No description'}p>
div>
span>⭐span>
span>{repo.stargazers_count.toLocaleString()}span>
div>
li>
),
[onRepositoryClick]
);
if (status === 'pending' && !data) {
return div>読み込み中...div>;
}
if (status === 'error') {
return div>エラーが発生しましたdiv>;
}
return (
>
{}
ul style={{ opacity: isPlaceholderData ? 0.7 : 1 }}>
{data?.map(renderRepository)}
ul>
{}
>
button
onClick={handlePreviousPage}
disabled={isPlaceholderData || isFirstPage}
>
前のページ
button>
span>
ページ {page}
{isPlaceholderData && span>(読み込み中...)span>}
span>
button
onClick={handleNextPage}
disabled={isPlaceholderData || isLastPage}
>
次のページ
button>
>
>
);
};
export default OptimizedRepoList;
この実装により、useQuery ベース手法の内部メカニズムの理解、型安全性の確保、パフォーマンスの最適化、UX の向上を同時に達成できます。特に、TanStack Query の Observer パターンや placeholderData の動作原理を理解することで、より効率的で保守性の高いページネーション実装が可能になります。
1-4. よくある実装上の注意点
useQuery ベース手法では、その柔軟性ゆえに型安全性を損なうパターンが存在します。以下の点に注意することで、より堅牢な実装を実現できます。
placeholderData の適切な使用
placeholderData に静的な値(空配列など)を設定しても意味がありません。関数形式で previousData
を返すことで、前回取得したデータを自動的に表示し続けることができます。
const { data, isPlaceholderData } = useQueryRepository[]>({
queryKey: ['repos', page],
queryFn: () => fetchRepos(page),
placeholderData: [] as Repository[],
});
const { data, isPlaceholderData } = useQueryRepository[], ApiError>({
queryKey: ['repos', page] as const,
queryFn: () => fetchRepos(page),
placeholderData: (previousData) => previousData,
});
最終ページ判定の型安全な実装
useQuery から返される data
は TData | undefined
型です。この特性を正しく理解せずに実装すると、実行時エラーの原因となります。
const isLastPage = data?.length === 0;
const isLastPage: boolean = data !== undefined && data.length PAGE_SIZE;
const getPaginationState = (
data: Repository[] | undefined,
pageSize: number
) => {
if (data === undefined) {
return {
isLastPage: false,
hasData: false,
itemCount: 0,
};
}
return {
isLastPage: data.length pageSize,
hasData: data.length > 0,
itemCount: data.length,
};
};
エラーハンドリングの型ガード
TanStack Query v5 では、エラーのデフォルト型が Error
になりました(v4 では unknown
でした)。API 固有のエラー情報を扱う場合は、適切な型ガードを実装することが重要です。
export class ApiError extends Error {
constructor(message: string, public status: number, public code?: string) {
super(message);
this.name = 'ApiError';
}
}
export function isApiError(error: unknown): error is ApiError {
return error instanceof ApiError;
}
const { data, error } = useRepos(page);
if (error && isApiError(error)) {
console.error(`API Error: ${error.message} (${error.status})`);
if (error.status === 404) {
return div>データが見つかりません/div>;
} else if (error.status >= 500) {
return div>サーバーエラーが発生しました/div>;
}
}
これらの注意点を理解することで、useQuery ベース手法の潜在的な問題を回避し、より安全で保守性の高い実装を実現できます。
useSuspenseQuery ベース手法は、React 18 で導入された Suspense と useTransition を活用したページネーション実装アプローチです。useQuery ベース手法とは根本的に異なる設計思想を持ち、宣言的な UI 構築と統一的な状態管理を実現します。
この手法の最大の特徴は、データの可用性保証です。useSuspenseQuery から返される data
は常に定義されており、コンポーネント内でのデータ存在チェックが不要になります。
2-1. Suspense 境界の基本設計とエラーハンドリング
Suspense を効果的に活用するためには、適切な境界設計が不可欠です。Suspense 境界は単なるローディング表示の仕組みではなく、アプリケーションアーキテクチャの重要な構成要素として機能します。
基本的な Suspense 境界の設定
Suspense 境界は、非同期処理中のフォールバック表示を定義します。この境界内のコンポーネントがデータ取得中の場合、指定されたフォールバック UI が表示されます。
import React, { Suspense } from 'react';
const App: React.FC = () => {
return (
>
header>
h1>リポジトリ一覧h1>
header>
main>
Suspense fallback={SuspenseFallback />}>
SuspenseRepos />
Suspense>
main>
>
);
};
export default App;
この構造により、SuspenseRepos
コンポーネントがデータ取得中の場合、自動的に SuspenseFallback
が表示されます。重要なのは、SuspenseRepos
コンポーネント自体はローディング状態を意識する必要がないことです。
包括的な ErrorBoundary の実装
Suspense と組み合わせて使用する ErrorBoundary は、アプリケーション全体のエラーハンドリング戦略において中核的な役割を果たします。react-error-boundary ライブラリを使用した実装例を示します。
import React from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
import { QueryErrorResetBoundary } from '@tanstack/react-query';
const ErrorFallback: React.FCFallbackProps> = ({
error,
resetErrorBoundary,
}) => {
return (
>
h2>エラーが発生しましたh2>
pre>{error.message}pre>
button onClick={resetErrorBoundary}>再試行button>
>
);
};
const handleError = (error: Error, errorInfo: { componentStack: string }) => {
console.error('ErrorBoundary caught an error:', error, errorInfo);
if (process.env.NODE_ENV === 'production') {
}
};
const AppWithErrorHandling: React.FC = () => {
return (
QueryErrorResetBoundary>
{({ reset }) => (
ErrorBoundary
FallbackComponent={ErrorFallback}
onError={handleError}
onReset={reset}
>
Suspense fallback={SuspenseFallback />}>
SuspenseRepos />
Suspense>
ErrorBoundary>
)}
QueryErrorResetBoundary>
);
};
QueryErrorResetBoundary は TanStack Query 特有の機能で、クエリエラーをリセットする機能を提供します。ErrorBoundary の onReset
プロパティと連携することで、エラー状態のクエリを適切にクリアし、再試行を可能にします。
2-2. useSuspenseQuery の実装とデータ管理
基本的な useSuspenseQuery フック
import { useSuspenseQuery } from '@tanstack/react-query';
import { Repository } from '../types/api';
export function useSuspenseRepos(page: number) {
return useSuspenseQuery({
queryKey: ['repos', { page }] as const,
queryFn: () => fetchRepos(page),
staleTime: 5 * 60 * 1000,
});
}
useSuspenseQuery の特徴は以下の通りです:
-
データ型の保証:
data
は常に定義されており、undefined
チェックが不要 - エラー処理の委譲: エラーは最寄りの ErrorBoundary で処理される
- Suspense との統合: React の Suspense 機能と完全に統合され、宣言的な非同期処理を実現
制約事項と設計上の理由
useSuspenseQuery には制約があります。これらは Suspense の設計思想と密接に関連しています。
const { data } = useSuspenseQuery({
queryKey: ['repos', page],
queryFn: () => fetchRepos(page),
enabled: page > 0,
placeholderData: previousData,
});
const { data } = useSuspenseQuery({
queryKey: ['repos', page],
queryFn: () => fetchRepos(page),
});
技術的制約の詳細
TanStack Query の useSuspenseQuery では、型定義レベルで以下のオプションが明示的に除外されています:
制約の理由と対策:
-
enabled
オプション不可: Suspense 境界での一貫した動作を保証するため、常にenabled: true
で強制実行されます。条件付きクエリが必要な場合は、通常のuseQuery
を使用するか、コンポーネント分割を検討してください。 -
placeholderData
不可: Suspense の「データが利用可能になるまでフォールバックを表示する」という思想と矛盾するため削除されました。代わりにuseTransition
を活用します。 -
throwOnError
不可: エラーは自動的に最寄りの ErrorBoundary に投げられるため、個別のエラーハンドリングは不要です。
useSuspenseQueryでplaceholderDataが使用できない理由
TanStack Query v5では、useSuspenseQuery
からplaceholderData
オプションが除外されています。これはSuspenseの設計思想に基づく技術的な決定です。
除外された理由
1. Suspenseの設計思想との整合性placeholderData
はSuspenseの「データが利用可能になるまでフォールバックを表示する」という基本思想と矛盾します。Suspenseは本来、データがない状態ではUIをサスペンドしてフォールバック(ローディング)を表示するように設計されています。
2. Reactの公式解決策の存在
TanStack Queryのメンテナーが指摘する通り、React 18で導入されたuseTransition
が公式な解決策として存在するため、TanStack Query独自のplaceholderData
は不要です。
3. ライブラリの責務の明確化
TanStack QueryはReactのSuspense機能と統合することに専念し、Reactが提供する標準的な機能(useTransition
)を使用することが適切とされています。
2-3. useTransition による滑らかなページ遷移
useTransition は React 18 で導入された機能で、UI の応答性を保ちながら状態更新を行えます。ページネーションにおいて、この機能は placeholderData の代替手段 として機能します。
基本的な実装パターン
useTransition は状態更新を「緊急」と「非緊急」に分類し、ユーザーインタラクションの応答性を優先します。
import React, { useState, useTransition } from 'react';
import { useSuspenseRepos } from '../hooks/useSuspenseRepos';
const SuspenseRepos: React.FC = () => {
const [page, setPage] = useState(1);
const [isPending, startTransition] = useTransition();
const { data } = useSuspenseRepos(page);
const handlePageChange = (newPage: number) => {
startTransition(() => {
setPage(newPage);
});
};
return (
>
{}
div style={{ opacity: isPending ? 0.7 : 1 }}>
{data.map((repo) => (
div key={repo.id}>
h3>{repo.full_name}h3>
{repo.description && p>{repo.description}p>}
span>⭐ {repo.stargazers_count.toLocaleString()}span>
div>
))}
div>
{}
>
button onClick={() => handlePageChange(page - 1)} disabled={isPending}>
前のページ
button>
span>
ページ {page} {isPending && '(更新中)'}
span>
button onClick={() => handlePageChange(page + 1)} disabled={isPending}>
次のページ
button>
>
>
);
};
useTransition の利点と注意点
利点:
- UI の応答性維持: ユーザーの操作に対して即座に反応し、データ取得は背景で行われる
- 滑らかな状態遷移: 前のデータを表示し続けながら新しいデータを取得
-
視覚的フィードバック:
isPending
フラグにより、データ更新中であることを適切に表示
注意点:
-
すべての関連状態更新を
startTransition
でラップする必要: ページネーション関連の状態更新は一貫して非緊急扱いにする - 適切な無効化処理: トランジション中の追加操作を適切に制御する
- 視覚的フィードバックの重要性: ユーザーがシステムの状態を理解できるよう、適切な UI フィードバックを提供する
2-4. 注意点とベストプラクティス
useSuspenseQuery ベース手法では、その宣言的な性質により、特有の注意点が存在します。以下の点を理解することで、より効率的な実装を実現できます。
ウォーターフォール問題と並列実行
useSuspenseQuery
を使用する際の重要な注意点として、同一コンポーネント内での複数クエリのシリアル実行があります。
なぜウォーターフォールが発生するのか
公式ドキュメントにも記載されている通り、Suspenseはコンポーネント全体を「サスペンド」させるため:
- 最初の
useSuspenseQuery
でコンポーネントがサスペンド - 最初のクエリが完了するまで、2番目のクエリのコードに到達しない
- 結果として、クエリが順次実行される
const SerialExecutionComponent: React.FC{ userId: number }> = ({ userId }) => {
const { data: user } = useSuspenseQueryUser>({
queryKey: ['user', userId] as const,
queryFn: () => fetchUser(userId),
});
const { data: posts } = useSuspenseQueryPost[]>({
queryKey: ['posts', userId] as const,
queryFn: () => fetchPosts(userId),
});
return >...>;
};
対処法
以下の 3 つのアプローチで並列実行を実現できます:
const UserDashboard: React.FC{ userId: number }> = ({ userId }) => {
const [userQuery, postsQuery] = useSuspenseQueries({
queries: [
{
queryKey: ['user', userId] as const,
queryFn: () => fetchUser(userId),
},
{
queryKey: ['posts', userId] as const,
queryFn: () => fetchPosts(userId),
},
],
});
const user = userQuery.data;
const posts = postsQuery.data;
return (
>
h2>{user.name}h2>
div>{posts.length} 件の投稿div>
>
);
};
const ParallelComponents: React.FC = () => {
return (
>
Suspense fallback={div>ユーザー情報を読み込み中...div>}>
UserProfile userId={1} />
Suspense>
Suspense fallback={div>投稿を読み込み中...div>}>
UserPosts userId={1} />
Suspense>
>
);
};
const AppWithPrefetch: React.FC = () => {
usePrefetchQuery({
queryKey: ['user', 1],
queryFn: () => fetchUser(1),
});
usePrefetchQuery({
queryKey: ['posts', 1],
queryFn: () => fetchPosts(1),
});
return (
Suspense fallback={Loading />}>
UserProfile userId={1} />
UserPosts userId={1} />
Suspense>
);
};
自動的な staleTime の設定
useSuspenseQuery
を使用する場合、自動的に短いstaleTime
が設定されます。これは、Suspenseのフォールバック表示中にコンポーネントがアンマウントされ、再マウント時に不要なバックグラウンドリフェッチを防ぐためです。
const { data } = useSuspenseQuery({
queryKey: ['repos', page],
queryFn: () => fetchRepos(page),
});
依存クエリの適切な実装
真に依存関係がある場合は、シリアル実行が適切です:
const DependentQueriesComponent: React.FC{ title: string }> = ({ title }) => {
const { data: movie } = useSuspenseQueryMovie>({
queryKey: ['movie', title],
queryFn: () => fetchMovie(title),
});
const { data: director } = useSuspenseQueryDirector>({
queryKey: ['director', movie.directorId],
queryFn: () => fetchDirector(movie.directorId),
});
return (
>
h1>{movie.title}h1>
p>監督: {director.name}p>
>
);
};
movie と director が依存関係にあるため、movie を取得しなければ director の ID が分からず、API 設計の変更などを行わない限り並列実行は不可能です。TanStack Query 公式ドキュメントでも、このような真の依存関係がある場合のシリアル実行は適切であると説明されています。
これらの注意点とベストプラクティスを理解することで、useSuspenseQuery ベース手法の潜在的な問題を回避し、最適なパフォーマンスを実現できます。
TanStack Query のページネーション実装において、useQuery ベース手法と useSuspenseQuery ベース手法の違いは単なる実装手法の差異を超えて、開発体験、保守性、型安全性の観点で影響を与えます。
3-1. 型システムの恩恵比較
TypeScript を使用する最大の利点は、コンパイル時の型チェックにより実行時エラーを事前に防げることです。TanStack Query の 2 つのアプローチは、この型システムの恩恵を受ける方法が根本的に異なります。
観点 | useQuery ベース手法 | useSuspenseQuery ベース手法 |
---|---|---|
エラー型の扱い |
UseQueryResult で明示的 |
エラーは throw され、ErrorBoundary で catch エラー型は ErrorBoundary で定義 |
データ可用性 |
data | undefined で条件分岐必要 |
data: Data 常に利用可能(non-nullable) |
ローディング状態 |
isLoading , isPending で状態管理 |
Suspense が処理、コンポーネント内での管理不要 |
キャッシュ型推論 |
queryKey の as const で型推論 |
queryKey の as const (同様) |
useQuery ベース手法の型システム活用
useQuery ベース手法では、明示的な型制御が可能です。これは、複雑なビジネスロジックを扱う場合や、段階的な型安全性の向上を目指す既存プロジェクトにおいて大きな利点となります。
特に重要なのは、UseQueryResult
という包括的な型定義により、データの状態(pending
、success
、error
)に応じた適切な型チェックが行われることです。これにより、開発者は各状態での適切な処理を強制され、未処理の状態によるバグを防ぐことができます。
また、data | undefined
という型により、データの存在チェックが TypeScript レベルで強制されます。これは一見煩雑に見えますが、実際にはnull pointer exception 的なエラー(存在しないオブジェクトに対して操作しようとした場合に発生するエラー)を防ぐ強力な仕組みです。
useSuspenseQuery ベース手法の型システム活用
一方、useSuspenseQuery ベース手法では、型レベルでのデータ可用性保証が最大の特徴です。Suspense 境界内でのデータは常に利用可能であることが型システムによって保証されるため、data
は non-nullable 型として扱われます。
これにより、コンポーネント内でのデータ存在チェックが不要となり、より宣言的なコードを書くことが可能になります。TypeScript の型推論も、この特性を活かしてより正確な型情報を提供できます。
3-2. 選択指針と実用的な考慮事項
両手法を選択する際の実用的な指針を以下に示します。
プロジェクトの性質による選択
既存プロジェクトの場合:useQuery ベース手法を推奨します。段階的な導入が可能で、既存のコードベースとの親和性が高いためです。特に、すでに複雑なエラーハンドリングロジックが存在する場合、それを活用しながら型安全性を向上させることができます。
新規プロジェクトの場合:useSuspenseQuery ベース手法を検討することを推奨します。初期設計段階から一貫した型安全性を実現でき、長期的な保守性向上が期待できます。
パフォーマンス要件による選択
複雑な状態管理が必要:useQuery ベース手法が適しています。細かな制御が可能で、パフォーマンス最適化の余地が大きいためです。
シンプルなデータ表示が中心:useSuspenseQuery ベース手法が適しています。宣言的な実装により、パフォーマンスとコードの簡潔性を両立できます。
このように、両手法はそれぞれ異なる強みを持っており、プロジェクトの要件と開発チームの特性に応じて適切に選択することが重要であると考えています。
本記事では、TanStack Query を使った 2 つのページネーション実装アプローチを比較しました。
なお、両手法共に、デフォルトでは Fetch-on-render パターン(コンポーネントマウント時にデータ取得開始)で動作しますが、queryClient.prefetchQuery()
を活用することで Render-as-you-fetch パターン(事前にデータを準備)による更なるパフォーマンス最適化も可能です。
どちらの手法も、プロジェクトの特性とチームの状況に応じて適切に選択することで、型安全で保守性の高いページネーション実装を実現できます。
以上です!
Views: 0