📄

Tanstack Query による 2 パターンのページネーション設計

に公開

はじめに 🚀

大量のデータを効率的に表示するページネーションは、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で並列実行可能)

1. useQuery ベース手法 ✅

useQuery ベースのページネーション実装は、useQueryplaceholderData を組み合わせた手法です。

1-1. useQuery の仕組みを理解する

TanStack Query は 「キャッシュファースト」 のアプローチを採用しています。つまり、データが必要になったとき、まずキャッシュを確認し、データがなければ API を呼び出します。

実際の動作フロー

GitHub API のページネーションを例に、TanStack Query がどのように動作するかを見てみましょう:

/**
 * GitHubリポジトリの型定義
 */
export type Repository = {
  id: number;
  full_name: string;
  description: string | null;
  stargazers_count: number;
};

/** 1ページあたりの表示件数 */
export const PAGE_SIZE = 4;

/**
 * TanStack組織のリポジトリ一覧を取得
 * @param page - 取得するページ番号(1から開始)
 * @returns リポジトリの配列をPromiseで返す
 * @throws {ApiError} APIリクエストが失敗した場合
 */
export async function fetchRepos(page: number): Promise<Repository[]> {
  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';

/**
 * リポジトリ一覧を取得するカスタムフック
 * @param page - 取得するページ番号
 * @returns TanStack Queryの結果オブジェクト
 */
export function useRepos(page: number) {
  return useQuery({
    // キャッシュのキー(このキーでデータが識別される)
    queryKey: ['repos', { page }] as const,

    // データ取得関数
    queryFn: () => fetchRepos(page),

    // 10秒間は「新鮮」とみなす(再取得しない)
    staleTime: 10 * 1000,
  });
}

この実装により、以下の動作が自動的に行われます:

  1. 初回呼び出し: ['repos', { page: 1 }] のキーでキャッシュを確認
  2. キャッシュミス: データがないので API を呼び出し
  3. キャッシュ保存: 取得したデータをキーと紐付けて保存
  4. 再利用: 同じキーで再度呼び出されたときはキャッシュから返す

ページ間移動でのキャッシュ動作

以下のように異なるページを行き来する場合を考えてみましょう:

const Component: React.FC = () => {
  const [page, setPage] = useState(1);

  // ページが変わるたびに呼ばれるが...
  const { data, isLoading } = useRepos(page);

  // 1ページ目 → 2ページ目 → 1ページ目と移動した場合:
  // 1ページ目(初回): API呼び出し → キャッシュ保存
  // 2ページ目: API呼び出し → キャッシュ保存
  // 1ページ目(再訪): キャッシュから即座に返却(isLoading = false)

  return (
    <>
      <div>{isLoading ? '読み込み中...' : `${data?.length}件のリポジトリ`}</div>
      <button onClick={() => setPage(page + 1)}>次のページ</button>
      <button onClick={() => setPage(page - 1)}>前のページ</button>
    </>
  );
};

1-2. placeholderData で滑らかな UX を実現する

通常のページネーションでは、ページを変更するたびに「読み込み中...」が表示され、ユーザー体験が途切れてしまいます。

placeholderData を使うことで、新しいデータを取得している間も前のデータを表示し続けることができます。

placeholderData なしの場合

ページ変更時の動作:

  1. 新しいクエリキーに対してキャッシュを確認
  2. データがない → isLoading = true
  3. 画面が「読み込み中...」表示に切り替わる
  4. API レスポンス → data 更新、isLoading = false
// 基本的な実装(placeholderDataなし)
const { data, isLoading } = useQuery({
  queryKey: ['repos', { page }],
  queryFn: () => fetchRepos(page),
});

placeholderData ありの場合

ページ変更時の動作:

  1. 新しいクエリキーに対してキャッシュを確認
  2. データがない → isLoading = true, data = 前のページのデータ
  3. 画面は前のデータを表示し続ける(読み込み中にならない)
  4. 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 の利点

  1. ユーザー体験の向上: ページ遷移時に画面が真っ白にならない
  2. 視覚的な継続性: 前のデータが表示されるため、操作の文脈が保たれる
  3. パフォーマンス感の向上: 実際の読み込み時間は変わらないが、体感速度が向上

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,
};

/**
 * 型安全なクエリオプションを生成
 * @param page - ページ番号
 * @returns TanStack Query用のオプションオブジェクト
 */
function createRepositoryQueryOptions(
  page: number
): UseQueryOptions<Repository[], Error, Repository[], RepositoryQueryKey> {
  return {
    queryKey: repositoryQueries.list({ page }),
    queryFn: async (): Promise<Repository[]> => {
      const result = await fetchRepos(page);

      // 実行時型検証(開発環境)
      if (process.env.NODE_ENV === 'development') {
        /**
         * リポジトリオブジェクトの型ガード
         * @param obj - 検証対象のオブジェクト
         * @returns Repository型として有効な場合true
         */
        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 QueryResult<T> = {
  data: T | undefined;
  isLoading: boolean;
  isError: boolean;
  error: Error | null;
};

/**
 * 型安全性を強化したリポジトリ取得フック
 * @param page - ページ番号
 * @returns クエリ結果とプリフェッチ関数を含むオブジェクト
 */
export function useTypedRepos(page: number): QueryResult<Repository[]> & {
  isPlaceholderData: boolean;
  prefetchNext: () => Promise<void>;
  prefetchPrevious: () => Promise<void>;
} {
  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 };

/**
 * ページネーション状態を管理するリデューサー
 * @param state - 現在の状態
 * @param action - 実行するアクション
 * @returns 新しい状態
 */
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; // 最初のページまたは1以下では変更しない
      const newPage = Math.max(1, state.page - 1); // 1未満にならないよう保証

      return {
        ...state,
        page: newPage,
        isFirstPage: newPage <= 1,
        isLastPage: false,
      };

    case 'GO_TO_PAGE':
      return {
        ...state,
        page: Math.max(1, action.page), // 1未満にはならない
        isFirstPage: action.page <= 1,
      };

    case 'SET_LAST_PAGE':
      return {
        ...state,
        isLastPage: action.isLastPage,
      };

    default:
      return state;
  }
}

/**
 * ページネーション状態を管理するカスタムフック
 * @param initialPage - 初期ページ番号(デフォルト: 1)
 * @returns [状態, アクション関数] のタプル
 */
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.FC<RepoListProps> = ({ 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 } = useQuery<Repository[]>({
  queryKey: ['repos', page],
  queryFn: () => fetchRepos(page),
  placeholderData: [] as Repository[], // 常に空配列は意味がない
});

// ✅ 正しい型安全な使い方
const { data, isPlaceholderData } = useQuery<Repository[], ApiError>({
  queryKey: ['repos', page] as const,
  queryFn: () => fetchRepos(page),
  placeholderData: (previousData) => previousData, // 型推論により安全
});

最終ページ判定の型安全な実装

useQuery から返される dataTData | undefined 型です。この特性を正しく理解せずに実装すると、実行時エラーの原因となります。

// ❌ 型チェックが不十分
const isLastPage = data?.length === 0; // undefinedの場合も考慮されていない

// ✅ 型安全な実装
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 固有のエラー情報を扱う場合は、適切な型ガードを実装することが重要です。

/** APIエラーの型定義(Error を拡張) */
export class ApiError extends Error {
  constructor(message: string, public status: number, public code?: string) {
    super(message);
    this.name = 'ApiError';
  }
}

/**
 * APIエラーの型ガード関数
 * @param error - 検証対象のエラーオブジェクト
 * @returns ApiError型として有効な場合true
 */
export function isApiError(error: unknown): error is ApiError {
  return error instanceof ApiError;
}

// 使用例
const { data, error } = useRepos(page);

if (error && isApiError(error)) {
  // この時点でerrorはApiError型として安全に扱える
  console.error(`API Error: ${error.message} (${error.status})`);

  // 状況に応じたエラーハンドリング
  if (error.status === 404) {
    return <div>データが見つかりません</div>;
  } else if (error.status >= 500) {
    return <div>サーバーエラーが発生しました</div>;
  }
}

これらの注意点を理解することで、useQuery ベース手法の潜在的な問題を回避し、より安全で保守性の高い実装を実現できます。

2. useSuspenseQuery ベース手法 🌟

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.FC<FallbackProps> = ({
  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') {
    // 例:Sentry, LogRocket などの監視サービスにエラーを送信
  }
};

// ErrorBoundary と QueryErrorResetBoundary の統合
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';

/**
 * Suspense対応のリポジトリ取得フック
 * @param page - ページ番号
 * @returns データが常に定義されたクエリ結果
 */
export function useSuspenseRepos(page: number) {
  return useSuspenseQuery({
    queryKey: ['repos', { page }] as const,
    queryFn: () => fetchRepos(page),
    // 必要に応じて staleTime を設定
    staleTime: 5 * 60 * 1000, // 5分
  });
}

useSuspenseQuery の特徴は以下の通りです:

  1. データ型の保証: data は常に定義されており、undefined チェックが不要
  2. エラー処理の委譲: エラーは最寄りの ErrorBoundary で処理される
  3. Suspense との統合: React の Suspense 機能と完全に統合され、宣言的な非同期処理を実現

制約事項と設計上の理由

useSuspenseQuery には制約があります。これらは Suspense の設計思想と密接に関連しています。

// ❌ useSuspenseQuery では利用できない機能
const { data } = useSuspenseQuery({
  queryKey: ['repos', page],
  queryFn: () => fetchRepos(page),
  enabled: page > 0, // ❌ enabledオプションは利用不可
  placeholderData: previousData, // ❌ placeholderDataも利用不可
});

// ✅ useSuspenseQuery の正しい使用法
const { data } = useSuspenseQuery({
  queryKey: ['repos', page],
  queryFn: () => fetchRepos(page),
  // データは常に利用可能であることが保証される
});

技術的制約の詳細

TanStack Query の useSuspenseQuery では、型定義レベルで以下のオプションが明示的に除外されています:

https://github.com/TanStack/query/blob/7cf6ef515fbdda6b79aa1640efd040391b13c7d2/packages/react-query/src/types.ts#L65-L73

制約の理由と対策:

  • 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)を使用することが適切とされています。

参考: https://github.com/TanStack/query/discussions/7013

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はコンポーネント全体を「サスペンド」させるため:

  1. 最初のuseSuspenseQueryでコンポーネントがサスペンド
  2. 最初のクエリが完了するまで、2番目のクエリのコードに到達しない
  3. 結果として、クエリが順次実行される
// ⚠️ シリアル実行になる例(useSuspenseQuery の特性)
const SerialExecutionComponent: React.FC<{ userId: number }> = ({ userId }) => {
  // 1. 最初のクエリが実行される
  const { data: user } = useSuspenseQuery<User>({
    queryKey: ['user', userId] as const,
    queryFn: () => fetchUser(userId),
  });

  // 2. 最初のクエリが完了してから2番目のクエリが実行される
  const { data: posts } = useSuspenseQuery<Post[]>({
    queryKey: ['posts', userId] as const, // userIdを使用(依存関係なし)
    queryFn: () => fetchPosts(userId),
  });

  // useQuery の場合は並列実行されるが、useSuspenseQuery では順次実行される
  return <>...</>;
};

対処法

以下の 3 つのアプローチで並列実行を実現できます:

// ✅ 解決策1: useSuspenseQueries で明示的に並列実行
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), // userIdを直接使用
      },
    ],
  });

  const user = userQuery.data; // 型推論により User 型
  const posts = postsQuery.data; // 型推論により Post[] 型

  return (
    <>
      <h2>{user.name}</h2>
      <div>{posts.length} 件の投稿</div>
    </>
  );
};

// ✅ 解決策2: 個別の Suspense 境界で並列実行を実現
const ParallelComponents: React.FC = () => {
  return (
    <>
      <Suspense fallback={<div>ユーザー情報を読み込み中...</div>}>
        <UserProfile userId={1} />
      </Suspense>
      <Suspense fallback={<div>投稿を読み込み中...</div>}>
        <UserPosts userId={1} />
      </Suspense>
    </>
  );
};

// ✅ 解決策3: プリフェッチによる対処
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のフォールバック表示中にコンポーネントがアンマウントされ、再マウント時に不要なバックグラウンドリフェッチを防ぐためです。

// useSuspenseQueryでは自動的に短いstaleTimeが適用される
const { data } = useSuspenseQuery({
  queryKey: ['repos', page],
  queryFn: () => fetchRepos(page),
  // staleTime: 1000, // 自動的に設定されるため、通常は明示的な指定は不要
});

依存クエリの適切な実装

真に依存関係がある場合は、シリアル実行が適切です:

// ✅ 真の依存関係がある場合のシリアル実行(ウォーターフォールだが適切)
const DependentQueriesComponent: React.FC<{ title: string }> = ({ title }) => {
  // 第1クエリ: 映画情報を取得
  const { data: movie } = useSuspenseQuery<Movie>({
    queryKey: ['movie', title],
    queryFn: () => fetchMovie(title),
  });

  // 第2クエリ: 監督情報を取得(movie.directorIdに依存)
  // movie.directorId が必要なため、並列実行は不可能
  const { data: director } = useSuspenseQuery<Director>({
    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 ベース手法の潜在的な問題を回避し、最適なパフォーマンスを実現できます。

3. ここまでの 2 つのアプローチの比較 📊

TanStack Query のページネーション実装において、useQuery ベース手法useSuspenseQuery ベース手法の違いは単なる実装手法の差異を超えて、開発体験保守性型安全性の観点で影響を与えます。

3-1. 型システムの恩恵比較

TypeScript を使用する最大の利点は、コンパイル時の型チェックにより実行時エラーを事前に防げることです。TanStack Query の 2 つのアプローチは、この型システムの恩恵を受ける方法が根本的に異なります。

観点 useQuery ベース手法 useSuspenseQuery ベース手法
エラー型の扱い UseQueryResult<Data, Error> で明示的 エラーは throw され、ErrorBoundary で catch
エラー型は ErrorBoundary で定義
データ可用性 data | undefined で条件分岐必要 data: Data 常に利用可能(non-nullable)
ローディング状態 isLoading, isPending で状態管理 Suspense が処理、コンポーネント内での管理不要
キャッシュ型推論 queryKeyas const で型推論 queryKeyas const(同様)

useQuery ベース手法の型システム活用

useQuery ベース手法では、明示的な型制御が可能です。これは、複雑なビジネスロジックを扱う場合や、段階的な型安全性の向上を目指す既存プロジェクトにおいて大きな利点となります。

特に重要なのは、UseQueryResult<TData, TError> という包括的な型定義により、データの状態(pendingsuccesserror)に応じた適切な型チェックが行われることです。これにより、開発者は各状態での適切な処理を強制され、未処理の状態によるバグを防ぐことができます。

また、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 パターン(事前にデータを準備)による更なるパフォーマンス最適化も可能です。

どちらの手法も、プロジェクトの特性とチームの状況に応じて適切に選択することで、型安全で保守性の高いページネーション実装を実現できます。

以上です!

chot Inc. tech blog

Discussion