概要
TanStack Query の useQuery と placeholderData を使い、クライアントサイドでスムーズなページネーションを実装する。ページ切り替え時に前のデータを表示し続けることでちらつきを防ぐ。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を使う isPlaceholderDataがtrueの間は前のデータを表示しているため、UI を半透明にするとフィードバックになるqueryKey: ["posts", page]でページごとにキャッシュが分離されるため、前のページに戻ったときもキャッシュが利用される- クエリキーにページ番号を含めることで、ページ変更時に自動的に再フェッチが走る
nextjs-pagination-urlとの使い分け: インタラクティブなフィルタ・ソートと組み合わせる場合は TanStack Query が適切