【Next.js】管理画面で使えるページネーションコンポーネントを作成
こんにちは、ライトです。
前回は、管理画面で使える DataTable コンポーネントを作成しました。
列定義を columns として渡し、データを data として渡すことで、ユーザー一覧や顧客一覧のような一覧画面を作りやすくしました。
ただ、実際の管理画面では、データが数件だけとは限りません。
たとえば、ユーザー一覧、顧客一覧、商品一覧、注文一覧などでは、データが数十件、数百件になることがあります。
そのような場合、すべてのデータを一度に表示すると画面が見づらくなります。
そこで今回は、管理画面で使える ページネーションコンポーネント を作成します。
管理画面でページネーションが必要になる場面
管理画面では、一覧表示を扱う場面が多くあります。
ユーザー一覧
顧客一覧
商品一覧
注文一覧
売上一覧
操作ログ一覧
このような一覧画面では、データ件数が多くなりやすいです。
データが多い場合、ページネーションがあると次のようなメリットがあります。
表示件数を絞れる
現在どの範囲を見ているか分かる
前後のページへ移動しやすい
一覧画面の表示が重くなりにくい
特に管理画面では、ただページ番号を表示するだけでなく、
全120件中 1〜10件を表示
のように、現在表示している件数の範囲も見せると分かりやすくなります。
今回作成するもの
今回は、以下のように使える Pagination コンポーネントを作成します。
<Pagination
currentPage={page}
totalPages={12}
totalItems={120}
pageSize={10}
pageSizeOptions={[10, 20, 50]}
onPageChange={(page) => setPage(page)}
onPageSizeChange={(size) => setPageSize(size)}
/>対応する機能は以下のものとなります。
現在ページを表示する
総ページ数を表示する
前へ・次へボタンを表示する
ページ番号を表示する
ページ数が多い場合は省略記号を表示する
全件数と現在の表示範囲を表示する
表示件数を切り替えられる
モバイルでは簡略表示にする
PCでは、件数表示、表示件数セレクト、ページ番号、前後ボタンを表示します。
モバイルでは、横幅が狭いため、ページ番号一覧は出さずに、シンプルに表示します。
完成形
以下のようなページネーションを作成します。


PCではページ番号を表示し、モバイルでは現在ページと総ページ数を表示する形にします。
ページネーションのレイアウト方針
管理画面向けのページネーションでは、以下のような構成が使いやすいと思います。
全120件中 1〜10件を表示 表示件数 10件 < 1 2 3 ... 12 >ただし、モバイルでは横幅が足りないため、次のように簡略化します。
全120件中 1〜10件を表示
< 前へ 1 / 12 次へ >デバイスによって表示内容を変えることで、どの画面幅でも無理なく使えるようにします。
ファイル構成
今回は、以下の構成で作成します。
components/
ui/
Pagination.tsx
app/
page.tsx
Pagination は DataTable と同じく、管理画面内のいろいろな一覧で使い回したいので、components/ui/ に作成します。
Pagination.tsxを作成する
それでは、Pagination.tsx を作成します。
components/ui/Pagination.tsx
type PaginationProps = {
currentPage: number;
totalPages: number;
totalItems: number;
pageSize: number;
pageSizeOptions?: number[];
onPageChange: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void;
};
type PageItem = number | "...";
function getPageItems(currentPage: number, totalPages: number): PageItem[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
if (currentPage <= 4) {
return [1, 2, 3, 4, 5, "...", totalPages];
}
if (currentPage >= totalPages - 3) {
return [
1,
"...",
totalPages - 4,
totalPages - 3,
totalPages - 2,
totalPages - 1,
totalPages,
];
}
return [
1,
"...",
currentPage - 1,
currentPage,
currentPage + 1,
"...",
totalPages,
];
}
export default function Pagination({
currentPage,
totalPages,
totalItems,
pageSize,
pageSizeOptions = [10, 20, 50],
onPageChange,
onPageSizeChange,
}: PaginationProps) {
const startItem =
totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1;
const endItem = Math.min(currentPage * pageSize, totalItems);
const pageItems = getPageItems(currentPage, totalPages);
const canGoPrev = currentPage > 1;
const canGoNext = currentPage < totalPages;
const handlePrev = () => {
if (!canGoPrev) return;
onPageChange(currentPage - 1);
};
const handleNext = () => {
if (!canGoNext) return;
onPageChange(currentPage + 1);
};
return (
<div className="flex flex-col gap-4 rounded-lg border border-slate-200 bg-white px-4 py-4 sm:px-5 lg:flex-row lg:items-center lg:justify-between">
<div className="text-sm text-slate-500">
全{totalItems}件中 {startItem}〜{endItem}件を表示
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between lg:justify-end">
{onPageSizeChange && (
<label className="flex items-center gap-2 text-sm text-slate-500">
<span>表示件数</span>
<select
value={pageSize}
onChange={(event) => {
onPageSizeChange(Number(event.target.value));
}}
className="h-9 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-primary-500"
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
{option}件
</option>
))}
</select>
</label>
)}
<div className="hidden items-center gap-1 sm:flex">
<button
type="button"
onClick={handlePrev}
disabled={!canGoPrev}
className="h-9 rounded-lg border border-slate-200 px-3 text-sm text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40"
>
前へ
</button>
<div className="flex items-center gap-1">
{pageItems.map((pageItem, index) => {
if (pageItem === "...") {
return (
<span
key={`ellipsis-${index}`}
className="flex h-9 min-w-9 items-center justify-center text-sm text-slate-400"
>
...
</span>
);
}
const isActive = pageItem === currentPage;
return (
<button
key={pageItem}
type="button"
onClick={() => onPageChange(pageItem)}
className={[
"flex h-9 min-w-9 items-center justify-center rounded-lg px-3 text-sm transition",
isActive
? "bg-primary-500 text-white"
: "text-slate-600 hover:bg-slate-50",
].join(" ")}
aria-current={isActive ? "page" : undefined}
>
{pageItem}
</button>
);
})}
</div>
<button
type="button"
onClick={handleNext}
disabled={!canGoNext}
className="h-9 rounded-lg border border-slate-200 px-3 text-sm text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40"
>
次へ
</button>
</div>
<div className="flex items-center justify-between gap-3 sm:hidden">
<button
type="button"
onClick={handlePrev}
disabled={!canGoPrev}
className="h-10 rounded-lg border border-slate-200 px-4 text-sm text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40"
>
前へ
</button>
<div className="text-sm font-medium text-slate-700">
{currentPage} / {totalPages}
</div>
<button
type="button"
onClick={handleNext}
disabled={!canGoNext}
className="h-10 rounded-lg border border-slate-200 px-4 text-sm text-slate-600 transition hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40"
>
次へ
</button>
</div>
</div>
</div>
);
}これで、PCとモバイルで表示が変わるページネーションコンポーネントができました。
propsの役割
Pagination で受け取るpropsは以下です。
type PaginationProps = {
currentPage: number; # 現在のページ番号
totalPages: number; # 総ページ数
totalItems: number; # 総件数
pageSize: number; # 1ページ当たりの表示件数
pageSizeOptions?: number[]; # 表示件数の選択肢
onPageChange: (page: number) => void; # ページを変更したときに呼ぶ関数
onPageSizeChange?: (pageSize: number) => void; # 表示件数を変更したときに呼ぶ関数
};onPageSizeChange は任意にしています。
表示件数を切り替えない一覧では、onPageSizeChange を渡さなければセレクトボックスは表示されません。
ページ番号の配列を作成する
ページ番号の表示には、getPageItems という関数を使っています。
type PageItem = number | "...";
function getPageItems(currentPage: number, totalPages: number): PageItem[] {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
if (currentPage <= 4) {
return [1, 2, 3, 4, 5, "...", totalPages];
}
if (currentPage >= totalPages - 3) {
return [
1,
"...",
totalPages - 4,
totalPages - 3,
totalPages - 2,
totalPages - 1,
totalPages,
];
}
return [
1,
"...",
currentPage - 1,
currentPage,
currentPage + 1,
"...",
totalPages,
];
}ページ数が少ない場合は、すべてのページ番号を表示します。
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}ページ数が多い場合は、省略記号を使います。
たとえば、現在ページが中央付近の場合は、次のようになります。
1 ... 4 5 6 ... 12このようにしておくと、ページ数が多くても表示が長くなりすぎません。
件数表示を作成する
管理画面では、現在どの範囲のデータを表示しているか分かると便利です。
そのため、以下のように表示します。
全120件中 1〜10件を表示この表示に使う値は、次のように計算しています。
const startItem =
totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1;
const endItem = Math.min(currentPage * pageSize, totalItems);たとえば、以下のような状態だとします。
currentPage: 2
pageSize: 10
totalItems: 35この場合、表示範囲は次のようになります。
全35件中 11〜20件を表示
最後のページでは、件数を超えないように Math.min を使っています。
const endItem = Math.min(currentPage * pageSize, totalItems);表示件数セレクトを作成する
表示件数を切り替えられるように、セレクトボックスも用意します。
{onPageSizeChange && (
<label className="flex items-center gap-2 text-sm text-slate-500">
<span>表示件数</span>
<select
value={pageSize}
onChange={(event) => {
onPageSizeChange(Number(event.target.value));
}}
className="h-9 rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-primary-500"
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
{option}件
</option>
))}
</select>
</label>
)}表示件数の候補は、デフォルトで [10, 20, 50] にしています。
使う側で変更することもできます。
<Pagination
pageSizeOptions={[10, 30, 100]}
...
/>前へ・次へボタンを作成する
前後のページへ移動するボタンも用意します。
const canGoPrev = currentPage > 1;
const canGoNext = currentPage < totalPages;1ページ目では「前へ」を無効にします。
disabled={!canGoPrev}最終ページでは「次へ」を無効にします。
disabled={!canGoNext}クリック時の処理は次のようにしています。
const handlePrev = () => {
if (!canGoPrev) return;
onPageChange(currentPage - 1);
};
const handleNext = () => {
if (!canGoNext) return;
onPageChange(currentPage + 1);
};無効な状態では、クリックしてもページが変わらないようにしています。
モバイル表示を調整する
PCやタブレットでは、ページ番号を表示します。
<div className="hidden items-center gap-1 sm:flex">
...
</div>hidden sm:flex にしているため、モバイルでは非表示になります。
モバイルでは、代わりに以下のような簡略表示を出します。
<div className="flex items-center justify-between gap-3 sm:hidden">
<button type="button" onClick={handlePrev} disabled={!canGoPrev}>
前へ
</button>
<div className="text-sm font-medium text-slate-700">
{currentPage} / {totalPages}
</div>
<button type="button" onClick={handleNext} disabled={!canGoNext}>
次へ
</button>
</div>モバイルでは、ページ番号をすべて並べると横幅が足りなくなりやすいです。
現在ページと総ページ数だけを表示します。
DataTableと組み合わせる
次に、前回作成した DataTable と組み合わせて使ってみます。
app/page.tsx
"use client";
import { useMemo, useState } from "react";
import DashboardLayout from "@/components/dashboard/DashboardLayout";
import DataTable, { DataTableColumn } from "@/components/ui/DataTable";
import Pagination from "@/components/ui/Pagination";
import StatusBadge from "@/components/ui/StatusBadge";
type User = {
id: number;
name: string;
email: string;
role: string;
status: "active" | "pending" | "suspended";
};
const users: User[] = [
{
id: 1,
name: "山田 太郎",
email: "yamada@example.com",
role: "管理者",
status: "active",
},
{
id: 2,
name: "佐藤 花子",
email: "sato@example.com",
role: "編集者",
status: "pending",
},
{
id: 3,
name: "田中 一郎",
email: "tanaka@example.com",
role: "閲覧者",
status: "suspended",
},
{
id: 4,
name: "鈴木 次郎",
email: "suzuki@example.com",
role: "編集者",
status: "active",
},
{
id: 5,
name: "高橋 美咲",
email: "takahashi@example.com",
role: "閲覧者",
status: "pending",
},
{
id: 6,
name: "伊藤 健",
email: "ito@example.com",
role: "管理者",
status: "active",
},
{
id: 7,
name: "渡辺 亮",
email: "watanabe@example.com",
role: "閲覧者",
status: "suspended",
},
{
id: 8,
name: "中村 彩",
email: "nakamura@example.com",
role: "編集者",
status: "active",
},
{
id: 9,
name: "小林 翔",
email: "kobayashi@example.com",
role: "閲覧者",
status: "pending",
},
{
id: 10,
name: "加藤 優",
email: "kato@example.com",
role: "管理者",
status: "active",
},
{
id: 11,
name: "吉田 葵",
email: "yoshida@example.com",
role: "編集者",
status: "active",
},
{
id: 12,
name: "山本 大輔",
email: "yamamoto@example.com",
role: "閲覧者",
status: "suspended",
},
];
function renderStatus(status: User["status"]) {
switch (status) {
case "active":
return <StatusBadge variant="success">有効</StatusBadge>;
case "pending":
return <StatusBadge variant="warning">承認待ち</StatusBadge>;
case "suspended":
return <StatusBadge variant="danger">停止中</StatusBadge>;
}
}
const columns: DataTableColumn<User>[] = [
{
key: "name",
header: "名前",
render: (user) => (
<div>
<div className="font-medium text-slate-900">{user.name}</div>
<div className="mt-1 text-xs text-slate-400">ID: {user.id}</div>
</div>
),
},
{
key: "email",
header: "メールアドレス",
render: (user) => (
<span className="text-slate-600">{user.email}</span>
),
},
{
key: "role",
header: "権限",
render: (user) => (
<span className="text-slate-600">{user.role}</span>
),
},
{
key: "status",
header: "ステータス",
render: (user) => renderStatus(user.status),
},
];
export default function Page() {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(5);
const totalItems = users.length;
const totalPages = Math.ceil(totalItems / pageSize);
const currentUsers = useMemo(() => {
const start = (page - 1) * pageSize;
const end = start + pageSize;
return users.slice(start, end);
}, [page, pageSize]);
const handlePageSizeChange = (nextPageSize: number) => {
setPageSize(nextPageSize);
setPage(1);
};
return (
<DashboardLayout>
<div className="space-y-6">
<section>
<p className="text-sm font-medium text-primary-700">Users</p>
<h2 className="mt-1 text-2xl font-semibold text-slate-950">
ユーザー一覧
</h2>
<p className="mt-2 max-w-2xl text-sm leading-7 text-slate-600">
DataTableとPaginationを組み合わせた一覧画面です。
表示件数やページ番号を切り替えられるようにしています。
</p>
</section>
<div className="space-y-4">
<DataTable
columns={columns}
data={currentUsers}
getRowKey={(user) => user.id}
emptyMessage="ユーザーが見つかりません。"
/>
<Pagination
currentPage={page}
totalPages={totalPages}
totalItems={totalItems}
pageSize={pageSize}
pageSizeOptions={[5, 10, 20]}
onPageChange={setPage}
onPageSizeChange={handlePageSizeChange}
/>
</div>
</div>
</DashboardLayout>
);
}これで、DataTable と Pagination を組み合わせたユーザー一覧画面ができます。
表示件数を変更したら1ページ目に戻す
表示件数を変更した場合は、現在ページを1ページ目に戻しています。
const handlePageSizeChange = (nextPageSize: number) => {
setPageSize(nextPageSize);
setPage(1);
};たとえば、5件ずつ表示していて3ページ目を見ていたとします。
そこで20件表示に変更すると、総ページ数が変わります。
そのまま3ページ目を表示しようとすると、存在しないページになる場合があります。
そのため、表示件数を変えたら1ページ目に戻すようにしています。
実装のポイント
今回のポイントは以下です。
Paginationを汎用コンポーネントとして作成する
現在ページ、総ページ数、総件数をpropsで渡す
表示件数を切り替えられるようにする
ページ数が多い場合は省略記号を使う
PCではページ番号を表示する
モバイルでは「現在ページ / 総ページ数」にする
DataTableと組み合わせて使う
管理画面では、一覧画面が多くなりやすいです。
そのため、DataTable と Pagination を別々のコンポーネントとして作っておくと、いろいろな画面で組み合わせて使えます。
まとめ
今回は、Next.jsで管理画面向けの Pagination コンポーネントを作成しました。
今回の実装では、以下に対応しました。
件数表示
表示件数の切り替え
前へ・次へボタン
ページ番号
省略記号
モバイル向けの簡略表示
DataTableとの組み合わせ
前回作成した DataTable と組み合わせることで、管理画面らしい一覧画面に近づきました。
一覧画面では、データ表示、状態表示、ページネーションがよく使われます。
今後は、検索や絞り込み、URLクエリ連携、API取得などを追加していくと、さらに実務に近い管理画面になりそうです。
いいなと思ったら応援しよう!
もしよろしければ応援をお願いいたします。
いただいたチップでコーヒーを飲んでがんばります!