React + Hooksで作るページネーションコンポーネント


Reactにはページネーションライブラリは複数ありますが、UIの自由度の面を考慮してページネーションコンポーネントを自作することがあると思います。

今回、自分も自作する機会があったので、コードを簡単にまとめてみます。

仕様

サーバー側の仕様は、APIのpathに/test/?page=2のようにクエリを指定することで決まった数量のデータが返ってくるというものです。

たとえば、データ件数が100件あったとして、1ページあたりのデータ件数が10件だった場合は、page=10までpathが存在することになります(存在しないpageを叩くと404)。

また、page=1は存在せず、最初の10件を取得したい場合はクエリをつけずにAPIを叩きます。

/test/なら最初の10件、/test/?page=2なら次の10件といった感じですね。

ページネーションコンポーネントは使いまわせるようにしたいので、他コンポーネントに依存性を持たせない形で実装します。

具体的にはこんな感じ。

export const ParentComponent: React.FC<Props> = props => {
  const handlePaginate = (page: number) => {
    // APIを叩きに行く処理
  };

  return (
    <div>
      {/* 親コンポーネントのrender内容 */}

      {/* ページ番号がクリックされるとhandlePaginateが実行される */}
      <Pagination
        sum={100}
        per={10}
        onChange={e => handlePaginate(e.page)}
      />
    </div>
  )
}

ページネーションコンポーネントが描画するページ番号をクリックすると、親コンポーネントにページ番号が渡されるので、それを使ってAPIを叩きます。

あとは、Reduxなどを使って描画される内容を更新すればOKというわけですね。

実装

ページネーションコンポーネントの最終的な実装内容はこんな感じになります。

import * as React from "react";

interface Props {
  sum: number;
  per: number;
  onChange: (e: { page: number }) => void;
}

export const Pagination: React.FC<Props> = props => {
  // 初回レンダリングかどうかを判定するための変数
  const isFirstRender = React.useRef(true);
  // 現在のページ番号
  const [currentPage, setPage] = React.useState(1);

  React.useEffect(() => {
    // 初回レンダリング時はスキップし、変数を更新する
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return;
    }

    // 親コンポーネントにpage番号を渡す
    props.onChange({ page: currentPage });
  }, [currentPage]);

  // ページ数
  const totalPage: number = Math.ceil(props.sum / props.per);

  // 「<」がクリックされたときの処理
  const handleBack = (): void => {
    if (currentPage === 1) {
      return;
    }

    setPage(currentPage - 1);
  };

  // 「>」がクリックされたときの処理
  const handleForward = (): void => {
    if (currentPage === totalPage) {
      return;
    }

    setPage(currentPage + 1);
  };

  // ページ番号を直接クリックされたときの処理
  const handleMove = (page: number): void => {
    setPage(page);
  };

  return (
    <div>
      {/* ページ番号が0(= アイテムが0個)のときは何も描画しない */}
      {totalPage !== 0 && (
        <>
          <span onClick={() => handleBack()}></span>
          <ul>
            {[...Array(totalPage).keys()].map(page => {
              page++;
              return page === currentPage ? (
                <li key={page} onClick={() => handleMove(page)}>
                  {page} active
                </li>
              ) : (
                <li key={page} onClick={() => handleMove(page)}>
                  {page}
                </li>
              );
            })}
          </ul>
          <span onClick={() => handleForward()}></span>
        </>
      )}
    </div>
  );
};

そこまで複雑な処理はないですが、簡単に解説します。

現在のページ番号はHooksの一種であるuseStateを使って管理しています。また、useEffectを使って、currentPageが更新されたタイミングで親コンポーネントにページ番号を渡すようにしています。

初回レンダリング時はページ番号を渡す必要はない(渡してもとくに問題はないかもしれないが、余計にAPIを叩きにいくのが嫌)ので、useRefを使って現在が初回レンダリングかどうかを判定しています。(useRefは前回の値を記憶しておけるHooks)

currentPageの更新は、handle~の3つの関数で処理しています。1ページ戻る、進む処理を行う際、ページ番号がマイナスになったり全ページ数を超えては困るので、そうなりうるケースでは早期リターンとしています。

総ページ数は「アイテムの合計数 / サーバーが一度に返すアイテムの件数」で求められます。この情報についてはページネーションコンポーネント自体は知ることができないので、親コンポーネントから渡してもらうようになっています。

ページ番号を描画する箇所で、[...Array(totalPage).keys()].map(...)という処理がありますが、これはnumberの値からそれに一致した要素を持つ配列を作り、続けてmap()とすることで1、2、3...というように描画させています。page++をしないと0スタートになるので注意。

現在のページをCSSで装飾して目立たせたい場合は、currentPageと一致する番号かどうかで判定するといいでしょう。

今回の実装では、ページ数が多くなった場合に前後のページを...のように省略する機能はつけていませんが、currentPageが1ページ目や最終ページから何ページ以上離れているかの判定関数を作り、その場合に一定範囲のページ番号を...に置き換える、みたいな処理を書けばOKです。

まとめ

Hooks便利ですよね。直感的に書けるので、全部これで済ませられれば世界はもっと幸せになるかもしれません。