Next.js のシンプルで汎用的なページネーションコンポーネント

## はじめに
Next.js や React で使用できるページネーション（ページ送り）コンポーネントを作りました。
ページ送り機能が必要となる度に、以前実装した内容をもとに調整していたのですが雛形となるようなものが欲しくなって作った次第です。

![c107fcd0c0efa8fe8a2eed6dcacdad28.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3368730/01e9b683-c3c2-4fde-aa95-9f236a720e95.gif)

- デモサイト（SSG）
https://k2webservice.xsrv.jp/r0105/the-pagination/

※デモサイトでは`fetch API`を用いてダミーデータ（[`https://jsonplaceholder.typicode.com/posts`](https://jsonplaceholder.typicode.com/posts)）をあてています。

今回これを紹介していきたいと思います。
もちろん、関心のある方は自由に使用・編集などを行っていただいて構いません。

https://github.com/Benjuwan/MyLibs/tree/main/frontend/the-pagination

## 技術構成
- @eslint/eslintrc@3.3.1
- @tailwindcss/postcss@4.1.4
- @types/node@20.17.30
- @types/react-dom@19.1.2
- @types/react@19.1.2
- eslint-config-next@15.3.0
- eslint@9.24.0
- next@15.3.0
- react-dom@19.1.0
- react@19.1.0
- tailwindcss@4.1.4
- typescript@5.8.3

## ページネーション（ページ送り）の仕組み
端的に述べると以下の仕組みとなります。
- 表示対象の配列を用意して、その配列を`slice`メソッドで分割することで表示するコンテンツ範囲をコントロール
- 「ページャー数」と「オフセット（ページごとに表示するコンテンツ）数」をグローバルステート管理して、それらを`slice`メソッドの開始値と終了値に指定する（＝ `0-15, 15-30, 30-45, 45-60...`というようにステートが更新されることで表示するコンテンツ範囲も変化する仕組み）

次に、具体的な実装や解説を述べていきます。

### 表示コンテンツの調整
ページネーション対象のデータを指定したオフセット数で`slice`しています。

```tsx
const adjustData: jsonPostType[] = useMemo(() => {
    // 開始値（データ表示開始位置）
    const begin: number = typeof getOffset !== 'undefined' ? 
      getOffset - OFFSET_NUMBER : pagerNum === 1 ? 0 : offset;
    // 終了値（データ表示終了位置）
    const finish: number = typeof getOffset !== 'undefined' ? 
      getOffset : pagerNum === 1 ? offset : offset + OFFSET_NUMBER;
    return [...getData].slice(begin, finish);
}, [offset]);
```

- 開始値
最初のページでは`0`を入れていて、最初のページ以外では「現在のオフセット数からページ送り数の固定値を差し引いた数値」または「現在のオフセット数」が入るようになっています。
  - デフォルトの固定値は15に設定しています
  ```tsx
  export const OFFSET_NUMBER: number = 15;
  ```
これにより、ページ送りしてオフセット数が更新される度にデータ表示開始位置（配列のインデックス数）も自動的に変わっていく仕組みです。

- 終了値
開始値と同じく、ページ送りしてオフセット数が更新される度にデータ表示終了位置（配列のインデックス数）が変わっていきます。
最初のページでは「現在のオフセット数（初期値は固定値）」を入れていて、最初のページ以外では「現在のオフセット数とページ送り数の固定値を足した数値」または「現在のオフセット数」が入るようになっています。

具体的な処理の流れとしては以下になります。

1. 表示中ページのURLから（例：`.../the-pagination/?pages=2-30`）ページネーション情報（`?pages=`）を取得する
```tsx
const getCurrUrlPath = useSearchParams();
    const targetPagesPathStr: string | null = getCurrUrlPath.get('pages');
    const getPager: number | undefined = targetPagesPathStr !== null ? parseInt(targetPagesPathStr.split('-')[0]) : 1; // false の場合は初期値を設定
    const getOffset: number | undefined = targetPagesPathStr !== null ? parseInt(targetPagesPathStr.split('-')[1]) : OFFSET_NUMBER; // false の場合は初期値を設定
```

2.（先に掲載した）ページネーション情報の有無に応じて表示するコンテンツの内容を調整する
```tsx
const adjustData: jsonPostType[] = useMemo(() => {
    // 開始値（データ表示開始位置）
    const begin: number = typeof getOffset !== 'undefined' ? 
      getOffset - OFFSET_NUMBER : pagerNum === 1 ? 0 : offset;
    // 終了値（データ表示終了位置）
    const finish: number = typeof getOffset !== 'undefined' ? 
      getOffset : pagerNum === 1 ? offset : offset + OFFSET_NUMBER;
    return [...getData].slice(begin, finish);
}, [offset]);
```

3.ページネーション情報の更新に応じて「ページャー数」と「オフセット数」の数値（グローバルステート）も更新
※デモサイトでは、グローバルステートに`Context API`を用いていますが、`jotai`や`zustand`など状態管理ライブラリを使っても良いかと思います。
```tsx
useEffect(() => {
    if (typeof getPager === 'undefined' || typeof getOffset === 'undefined') {
        return;
    }
    setPagerNum(getPager);  // ページャ数を更新
    setOffset(getOffset);   // オフセット数を更新
}, [getCurrUrlPath]);
```

<details><summary>上記コードの全体像</summary>

```tsx
function PagerContents({ getData }: { getData: jsonPostType[] }) {
    const { pagerNum, setPagerNum, offset, setOffset } = useContext(PagerContext);

    const getCurrUrlPath = useSearchParams();
    const targetPagesPathStr: string | null = getCurrUrlPath.get('pages');
    const getPager: number | undefined = targetPagesPathStr !== null ? parseInt(targetPagesPathStr.split('-')[0]) : 1; // false の場合は初期値を設定
    const getOffset: number | undefined = targetPagesPathStr !== null ? parseInt(targetPagesPathStr.split('-')[1]) : OFFSET_NUMBER; // false の場合は初期値を設定

    useEffect(() => {
        if (typeof getPager === 'undefined' || typeof getOffset === 'undefined') {
            return;
        }
        setPagerNum(getPager);  // ページャ数を更新
        setOffset(getOffset);   // オフセット数を更新
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [getCurrUrlPath]);

    const adjustData: jsonPostType[] = useMemo(() => {
        // 開始値
        const begin: number = typeof getOffset !== 'undefined' ? getOffset - OFFSET_NUMBER :
            pagerNum === 1 ? 0 : offset;
        // 終了値
        const finish: number = typeof getOffset !== 'undefined' ? getOffset :
            pagerNum === 1 ? offset : offset + OFFSET_NUMBER;
        return [...getData].slice(begin, finish);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, [offset]);

    return (
        <div className="contens mb-[5em] md:grid md:gap-[2em] md:grid-cols-[repeat(3,1fr)]">
            {
                adjustData.map(data => (
                    <article key={data.id} className="bg-[#eaeaea] p-[1em] rounded not-last-of-type:mb-[2.5em]">
                        <p className="text-sm">- data:id | {data.id}</p>
                        <h3 className="text-lg font-bold border-b border-b-dotted border-b-[#333] pb-[.5em] mb-[.5em]">{data.title}</h3>
                        <p>{data.body}</p>
                    </article>
                ))
            }
        </div>
    );
}
```

</details>

### ページ送りボタン
コンテンツ数に応じたページャーボタンの生成と、シンプルな「前へ/次へボタン」を用意しています。

- コンテンツ数に応じたページャーボタンの生成
```tsx
const createPagers: () => number[] = () => {
    let srcNum: number = maxPage;

    // 各ページャー項目を生成
    // srcNum（引算用途の上限数値）が 0 を切るまでオフセット数を倍数していくループ処理
    let Accumuration = 0;
    while (srcNum >= 0) {
        Accumuration++;
        srcNum = srcNum - OFFSET_NUMBER;
    }

    //（コンテンツデータに応じた）ページャー（項目）数の要素を持つ配列を用意して（初期値として）0をセット
    // map 処理で各初期値をインデックスインクリメント（順次繰り上げ）した数に置換（加工）する
    return Array(Accumuration).fill(0).map((_, i) => i + 1);
}
const thePagers: number[] = createPagers();
```

ループ処理を通じてコンテンツデータに応じたページャー項目を生成しています。
あとはそれを描画するだけです。

<details><summary>全体としては以下になります</summary>

```tsx
function Pagers({ maxPage }: { maxPage: number }) {
    const { pagerNum } = useContext(PagerContext);

    const createPagers: () => number[] = () => {
        let srcNum: number = maxPage;

        // 各ページャー項目を生成
        // srcNum（引算用途の上限数値）が 0 を切るまでオフセット数を倍数していくループ処理
        let Accumuration = 0;
        while (srcNum >= 0) {
            Accumuration++;
            srcNum = srcNum - OFFSET_NUMBER;
        }

        //（コンテンツデータに応じた）ページャー（項目）数の要素を持つ配列を用意して（初期値として）0をセット
        // map 処理で各初期値をインデックスインクリメント（順次繰り上げ）した数に置換（加工）する
        return Array(Accumuration).fill(0).map((_, i) => i + 1);
    }
    const thePagers: number[] = createPagers();

    const scrollTop: () => void = () => {
        // ページ遷移の度にスクロールトップ
        window.scrollTo(0, 0);
    }

    return (
        <div className="w-full flex flex-wrap justify-start items-center gap-[1em] mb-[2em] md:justify-center">
            {thePagers.map(pager => (
                <Link
                    key={pager}
                    href={`${ROUTING_PASS}${pager}-${OFFSET_NUMBER * pager}`}
                    className="rounded-full grid place-items-center text-xs bg-[#333] text-white border border-transparent text-center w-[2rem] h-[2rem] transition duration-[.25s] hover:bg-white hover:text-[#333] hover:border-[#333] active:bg-white active:text-[#333] data-[current=true]:pointer-events-none data-[current=true]:bg-[#2b2bd3] data-[current=true]:text-[#fff]"
                    data-current={pagerNum === pager}
                    onClick={scrollTop}
                >{pager}</Link>
            ))}
        </div>
    );
}

/* 前へ / 次へ ページャボタン */
function PagerBtns({ maxPage }: { maxPage: number }) {
    const { pagerNum, offset } = useContext(PagerContext);

    // 無効化処理
    const prevAction = () => {
        if (pagerNum === 1) {
            return;
        }
        // ページ遷移の度にスクロールトップ
        window.scrollTo(0, 0);
    }

    // 無効化処理
    const nextAction = () => {
        if (pagerNum > Math.floor(maxPage / OFFSET_NUMBER)) {
            return;
        }
        // ページ遷移の度にスクロールトップ
        window.scrollTo(0, 0);
    }

    return (
        <div className="ctrlBtns flex flex-wrap justify-between items-center">
            <Pagers maxPage={maxPage} />
            <Link
                href={pagerNum === 1 ? `${ROUTING_PASS}${pagerNum}-${OFFSET_NUMBER}` : `${ROUTING_PASS}${pagerNum - 1}-${offset - OFFSET_NUMBER}`}
                className="rounded bg-[#333] text-white border border-transparent text-center leading-[2.75rem] w-fit px-[1em] transition duration-[.25s] hover:bg-white hover:text-[#333] hover:border-[#333] active:bg-white active:text-[#333] active:border-[#333] data-[disabled=true]:pointer-events-none data-[disabled=true]:bg-[#919191] data-[disabled=true]:text-[#dadada]"
                data-disabled={pagerNum === 1}
                onClick={prevAction}
            >前へ</Link>
            <Link
                href={`${ROUTING_PASS}${pagerNum + 1}-${offset + OFFSET_NUMBER}`}
                className="rounded bg-[#333] text-white border border-transparent text-center leading-[2.75rem] w-fit px-[1em] transition duration-[.25s] hover:bg-white hover:text-[#333] hover:border-[#333] active:bg-white active:text-[#333] active:border-[#333] data-[disabled=true]:pointer-events-none data-[disabled=true]:bg-[#919191] data-[disabled=true]:text-[#dadada]"
                data-disabled={pagerNum > Math.floor(maxPage / OFFSET_NUMBER)}
                onClick={nextAction}
            >次へ</Link>
        </div>
    );
}
```

</details>

このページャーボタンは`Link`コンポーネントで用意しており、こちらでルーティング関連を担っているつくりになります。

これから説明する2種類のページャーボタンに関して`ROUTING_PASS`という変数が出てきます。
これはルーティングパスのメタ設定用の変数になっています。

```ts
/* 下層ページの場合はディレクトリ文字列を前置する（ /下層ディレクトリ名?pages= ）*/
export const ROUTING_PASS: string = "?pages=";
```

#### コンテンツ数に応じたページャーボタンでのルーティング
```tsx
{thePagers.map(pager => (
   <Link
      key={pager}
      {/* 例：ROUTING_PASS（.../the-pagination/?pages=）2-30 */}
      href={`${ROUTING_PASS}${pager}-${OFFSET_NUMBER * pager}`}
      data-current={pagerNum === pager}
      onClick={scrollTop}
   >{pager}</Link>
))}
```

「各ページャ項目」と「オフセットの固定値 × 各ページャ項目の掛け合わせ」でページ送り用のルーティングパスを実現しています。
```tsx
href={`${ROUTING_PASS}${pager}-${OFFSET_NUMBER * pager}`}
```

#### 前へ / 次へボタンでのルーティング
```tsx
<Link
  href={pagerNum === 1 ? ${ROUTING_PASS}${pagerNum}-${OFFSET_NUMBER} : `${ROUTING_PASS}${pagerNum - 1}-${offset - OFFSET_NUMBER}`}
  data-disabled={pagerNum === 1}
  onClick={prevAction}
>前へ</Link>
<Link
  href={`${ROUTING_PASS}${pagerNum + 1}-${offset + OFFSET_NUMBER}`}
  data-disabled={pagerNum > Math.floor(maxPage / OFFSET_NUMBER)}
  onClick={nextAction}
>次へ</Link>
```

こちらでは「ページャ数（グローバルステート）」と「現在のオフセット数とオフセットの固定値との計算値」でページ送り用のルーティングパスを実現しています。

※「前へ」ページャーボタンでは、最初のページでは`data-disabled`（無効化）属性が働くのでルーティングの条件分岐は必要ないものの機能理解促進の観点から記述しています。

これらページャーボタンでルーティングすると、コンテンツ表示用コンポーネントの副作用でページャー数とオフセット数が更新される仕組みです。

```tsx
useEffect(() => {
    if (typeof getPager === 'undefined' || typeof getOffset === 'undefined') {
        return;
    }
    setPagerNum(getPager);  // ページャ数を更新
    setOffset(getOffset);   // オフセット数を更新
}, [getCurrUrlPath]);
```

---

ページネーション機能に必要となるのは、これまで説明してきた「表示コンテンツの調整」コンポーネントと「ページ送りボタン」コンポーネントの2つとなります。

完成品（デモ）が以下になります。

![c107fcd0c0efa8fe8a2eed6dcacdad28.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3368730/01e9b683-c3c2-4fde-aa95-9f236a720e95.gif)

- デモサイト（SSG）
https://k2webservice.xsrv.jp/r0105/the-pagination/

## さいごに
個人的な用途で作成したページネーションの汎用コンポーネントですが、もしかしたら誰かの役に立てるかもと記事にしました。

冒頭でも書きましたが、関心のある方は自由に使用・編集などを行っていただいて構いません。

https://github.com/Benjuwan/MyLibs/tree/main/frontend/the-pagination

ここまで読んでいただき、ありがとうございました。