レビュー済み·難易度: 中級·更新: 2026-04-18

TanStack Query でクライアントサイドページネーションを実装する

useQuery の page パラメータと keepPreviousData を使い、クライアントサイドでスムーズなページネーションを実装する例。

nextjspaginationapitanstack-query

対応バージョン

nextjs 15react 19tanstack-query 5

前提環境

TanStack Query の useQuery の基本を理解していること

概要

TanStack Query の useQueryplaceholderData を使い、クライアントサイドでスムーズなページネーションを実装する。ページ切り替え時に前のデータを表示し続けることでちらつきを防ぐ。nextjs-pagination-url との違いはクライアント側フェッチ vs サーバー側レンダリング。

インストール

npm install @tanstack/react-query

実装

QueryClient のセットアップ

// app/providers.tsx
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}
// app/layout.tsx
import { Providers } from "./providers";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ja">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

ページネーションコンポーネント

// app/posts/PostList.tsx
"use client";

import { useQuery } from "@tanstack/react-query";
import { useState } from "react";

type Post = { id: number; title: string; body: string };
type ApiResponse = { data: Post[]; total: number };

const PER_PAGE = 10;

async function fetchPosts(page: number): Promise<ApiResponse> {
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${PER_PAGE}`
  );
  if (!res.ok) throw new Error("fetch failed");
  const data: Post[] = await res.json();
  const total = Number(res.headers.get("x-total-count") ?? 100);
  return { data, total };
}

export function PostList() {
  const [page, setPage] = useState(1);

  const { data, isLoading, isPlaceholderData } = useQuery({
    queryKey: ["posts", page],
    queryFn: () => fetchPosts(page),
    // v5: keepPreviousData の代替。ページ切り替え時に前データを維持してちらつきを防ぐ
    placeholderData: (prev) => prev,
  });

  const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 1;

  if (isLoading) return <p>読み込み中...</p>;

  return (
    <div>
      <ul className={`mb-6 space-y-2 ${isPlaceholderData ? "opacity-50" : ""}`}>
        {data?.data.map((post) => (
          <li key={post.id} className="rounded border p-3">
            <p className="font-medium">{post.title}</p>
          </li>
        ))}
      </ul>

      <div className="flex items-center gap-4">
        <button
          onClick={() => setPage((p) => Math.max(1, p - 1))}
          disabled={page === 1}
          className="rounded border px-3 py-1 text-sm disabled:opacity-40"
        >
          前へ
        </button>

        <span className="text-sm text-gray-600">
          {page} / {totalPages}
        </span>

        <button
          onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
          disabled={page === totalPages || isPlaceholderData}
          className="rounded border px-3 py-1 text-sm disabled:opacity-40"
        >
          次へ
        </button>
      </div>
    </div>
  );
}
// app/posts/page.tsx
import { PostList } from "./PostList";

export default function PostsPage() {
  return (
    <main className="mx-auto max-w-2xl p-8">
      <h1 className="mb-6 text-2xl font-bold">記事一覧</h1>
      <PostList />
    </main>
  );
}

ポイント

  • TanStack Query v5 では keepPreviousData オプションが廃止。代わりに placeholderData: (prev) => prev を使う
  • isPlaceholderDatatrue の間は前のデータを表示しているため、UI を半透明にするとフィードバックになる
  • queryKey: ["posts", page] でページごとにキャッシュが分離されるため、前のページに戻ったときもキャッシュが利用される
  • クエリキーにページ番号を含めることで、ページ変更時に自動的に再フェッチが走る
  • nextjs-pagination-url との使い分け: インタラクティブなフィルタ・ソートと組み合わせる場合は TanStack Query が適切

注意点

TanStack Query v5 では keepPreviousData オプションが廃止され、placeholderData: (prev) => prev を使う。nextjs-pagination-url との違いはクライアント側フェッチ vs サーバー側レンダリング。

関連サンプル