0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

よくあるページネーション ロジックをTypeScriptで。

0
Last updated at Posted at 2026-02-23

概要

ページネーション UI で「どのページ番号を表示するか」を決めるロジック。全ページ番号を並べると画面に入らないため、省略(ellipsis ...)を使って必要な部分だけ表示する。毎回使う時になったら調べて毎回忘れてなんだっけとなるので。いろいろあると思うけど、1例として捉えていただけるとありがたい。

解決したい問題

ページが 100 ページあるとき、ボタンを 100 個並べるわけにはいかない。

NG: [1] [2] [3] [4] [5] ... [98] [99] [100]  ← 100個は無理

ユーザーが知りたいのは 3 つだけ:

  1. 今どこにいるか → currentPage とその前後
  2. 最初に戻れるか → 1 ページ目
  3. 最後まであるか → 最終ページ

設計: 3 グループ + 省略記号

表示するページ番号を 3 グループに分ける:

グループ 内容 常に表示
A: 先頭 1
B: 周辺 currentPage - 1currentPage + 1
C: 末尾 totalPages

グループ間が離れていたら ...(ellipsis)を挟み、くっついていたら挟まない。

具体例(totalPages = 10)

currentPage = 1:   [1] [2] ... [10]
currentPage = 3:   [1] [2] [3] [4] ... [10]
currentPage = 5:   [1] ... [4] [5] [6] ... [10]
currentPage = 8:   [1] ... [7] [8] [9] [10]
currentPage = 10:  [1] ... [9] [10]

なぜ start / end が必要か

グループ B(周辺)を単純に currentPage - 1currentPage + 1 で計算すると、グループ A(先頭)やグループ C(末尾)と重複するケースがある。

currentPage = 1 の場合:
  グループ A: [1]
  グループ B: [0, 1, 2]  ← 0 は存在しない、1 は A と被る

currentPage = 10 の場合(totalPages = 10):
  グループ B: [9, 10, 11]  ← 11 は存在しない、10 は C と被る
  グループ C: [10]

だから境界をガードする:

  • start = Math.max(2, currentPage - 1) → A(1)と被らないよう 2 以上
  • end = Math.min(totalPages - 1, currentPage + 1) → C(totalPages)と被らないよう totalPages - 1 以下

ellipsis の出し分け

  • A と B の間: currentPage > 3 なら ellipsis(1 と周辺の間にページが抜ける)
  • B と C の間: currentPage < totalPages - 2 なら ellipsis(周辺と末尾の間にページが抜ける)

なぜ currentPage > 3

currentPage = 3 → 周辺は [2, 3, 4]、先頭 [1] と合わせると [1, 2, 3, 4] → 隙間なし
currentPage = 4 → 周辺は [3, 4, 5]、先頭 [1] と合わせると [1, ..., 3, 4, 5] → 2 が抜ける → ellipsis 必要

つまり ...が出るのは currentPageが3より大きい時。

なぜ currentPage < totalPages - 2

currentPage = 8 → 周辺は [7, 8, 9]、先頭 [1, ...] と合わせると [1, ..., 7, 8, 9, 10] → 隙間なし
currentPage = 7 → 周辺は [6, 7, 8]、先頭 [1, ...] と合わせると [1, ..., 6, 7, 8, ..., 10] → 9 が抜ける → ellipsis 必要

つまり...が出るのは currentPageが8より小さい時であり、この場合8を一般化して
currentPage < 8 = totalPages - 2 ということになる。

totalPages <= 5 のとき

全部表示しても 5 個以下なので省略不要。そのまま [1, 2, 3, 4, 5] を返す。

実装

function generatePageNumbers(
  currentPage: number,
  totalPages: number
): (number | "ellipsis")[] {
  // 5ページ以下なら全部表示
  if (totalPages <= 5) {
    return Array.from({ length: totalPages }, (_, i) => i + 1);
  }

  const pages: (number | "ellipsis")[] = [];

  // グループ A: 先頭
  pages.push(1);

  // A-B 間の ellipsis
  if (currentPage > 3) {
    pages.push("ellipsis");
  }

  // グループ B: 周辺(A, C と被らないようにクランプ)
  const start = Math.max(2, currentPage - 1);
  const end = Math.min(totalPages - 1, currentPage + 1);
  for (let i = start; i <= end; i++) {
    pages.push(i);
  }

  // B-C 間の ellipsis
  if (currentPage < totalPages - 2) {
    pages.push("ellipsis");
  }

  // グループ C: 末尾
  pages.push(totalPages);

  return pages;
}

まとめ

  • ellipsis が必要な理由: 全ページ番号は表示しきれないから
  • 3 グループに分ける理由: ユーザーが知りたい情報(現在地・先頭・末尾)を過不足なく見せるため
  • start / end が必要な理由: 先頭・末尾グループと周辺グループの重複を防ぐため

今回のパターンだと最低限のpagenationを作るには

  • 今のページ
  • 合計ページ数

だけあればいけるってことになる。
あとはこの値をbackendから API レスポンスとしてもらうなりすればいける。
自分の場合だと、Railsのkaminari使っているので、 そこからの情報を元にいけた。

おまけ

先ほどのRailsのkaminariからのレスポンスと shadcn/uiの Pagenationを使った例はこんな。

export function PostsPagination({ meta }: PostsPaginationProps) {
  const { currentPage, totalPages, nextPage, prevPage } = meta;

  if (totalPages <= 1) return null;

  const pageNumbers = generatePageNumbers(currentPage, totalPages);

  return (
    <Pagination>
      <PaginationContent>
        <PaginationItem>
          {prevPage ? (
            <PaginationPrevious href={`?page=${prevPage}`} />
          ) : (
            <PaginationPrevious
              className="pointer-events-none opacity-50"
              aria-disabled="true"
            />
          )}
        </PaginationItem>

        {pageNumbers.map((page, index) =>
          page === "ellipsis" ? (
            <PaginationItem key={`ellipsis-${index}`}>
              <PaginationEllipsis />
            </PaginationItem>
          ) : (
            <PaginationItem key={page}>
              <PaginationLink
                href={`?page=${page}`}
                isActive={page === currentPage}
              >
                {page}
              </PaginationLink>
            </PaginationItem>
          )
        )}

        <PaginationItem>
          {nextPage ? (
            <PaginationNext href={`?page=${nextPage}`} />
          ) : (
            <PaginationNext
              className="pointer-events-none opacity-50"
              aria-disabled="true"
            />
          )}
        </PaginationItem>
      </PaginationContent>
    </Pagination>
  );
}

見栄えはこんな。

image.png

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?