📌

HOC(higher-order component)を使った Pagination の実装

2022/11/05に公開約5,600字

何度作ったか分からない Pagination の実装を記録しておこうと思いまとめました。高階コンポーネント(higher-order component; HOC)をふんだんに使用しているので、実装サンプルとしても見て頂けます。

成果物

最初と最後、隣のページが表示される Pagination です。totallimitcurrent を調整することで表示が変わります。

仕様

今回実装する <Pagination> コンポーネントは次のインターフェイスを持ちます。

type PaginationProps = {
  total: number; // 最大項目数
  limit: number; // ページ掲載数上限
  currentPage: number; // 現在のページ数
  onSelectPage: (pageNumber: number) => void; // 選択したページ番号をコールバックに渡す
};

ファイル構成としては次のようになっています。

- Pagination
  - index.ts // 関数コンポーネント Pagination のみを export している
  - Pgination.tsx // 関数コンポーネント Pagination 本体
  - pagi-link.tsx // ページリンクの関数コンポーネントに関連したコード
  - Ellipsis.tsx // Pagination の ... 部分

今回の Pagination の実装で一番重要なのは選択肢の表示ロジックです。最大項目数、ページ掲載数上限、現在のページ数によりいくつかのパターンがあります。

今回は前後のページに移動するボタンを除いて、最大 5 つのページ番号リンクを表示する仕様とします。最初と最後のページは常に表示し、また現在表示しているページの前後のページ番号も常に表示するとします。

5 ページまでであれば全て表示
< □ □ □ □ □ > 

6 ページ以上ある場合は一部 … を表示
■ は現在表示しているページ
< ■ □ … □ >
< □ ■ □ … □ >
< □ □ ■ □ … □ >
< □ … □ ■ □ … □ >
< □ … □ ■ □ □ >
< □ … □ ■ □ >
< □ … □ ■ >

表示ロジックに関しては要件により仕様が常に異なりますが、条件を調整することで対応が可能です。

Pagination.tsx

一番重要な表示ロジックを実装した lineUpPageLinks について紹介します。

6 ページ未満と以上の場合でロジックを分けています。6 ページ未満の場合については数字のボタンリンクを並べているだけなので説明は割愛します。

6 ページ以上ある場合の表示ロジック

ボタンごとに表示ロジックを分けます。分かりやすいようにボタンを 90 度回転させた図で説明します。すべて高階コンポーネント(HOC)により別コンポーネントとして表現しています。

表示ロジック
コンポーネントごとの表示ロジック

上図のように整理すると複雑になりがちなロジックが整理されます。

この実装が以下になります。

src/Pagination/Pagination.tsx
// 省略

type lineUpPageLinksProps = {
  lastPage: number;
  currentPage: number;
  pageLink: PageLink; // ページ番号を与えることでボタンを出力する高階コンポーネント
};

const lineUpPageLinks = ({
  lastPage,
  currentPage,
  pageLink
}: lineUpPageLinksProps) => {
  // ページ数が 5 までの場合は単純にリンクを並べる
  if (lastPage < 6) {
    const pageLinks = [...Array(lastPage)].map((_, index) =>
      pageLink({ page: index + 1 })
    );
    return () => (
      <>
        {pageLinks.map((PageLink, index) => (
          <PageLink key={index} />
        ))}
      </>
    );
  }

  // 5 ページ以上ある場合
  const Void = () => null; // 何も表示しない関数コンポーネント
  const FirstPageLink = pageLink({ page: 1 });
  const PreviousEllipsis = currentPage > 3 ? Ellipsis : Void;
  const PreviousPageLink =
    currentPage > 2 ? pageLink({ page: currentPage - 1 }) : Void;
  const CurrentPageLink =
    currentPage !== 1 && currentPage !== lastPage
      ? pageLink({ page: currentPage })
      : Void;
  const NextPageLink =
    currentPage < lastPage - 1 ? pageLink({ page: currentPage + 1 }) : Void;
  const NextEllipsis = currentPage < lastPage - 2 ? Ellipsis : Void;
  const LastPageLink = pageLink({ page: lastPage });

  return () => (
    <>
      <FirstPageLink /> {/* 左端のリンク */}
      <PreviousEllipsis /> {/* 左側の...の表示 */}
      <PreviousPageLink /> {/* 現在ページの一つ前のリンク */}
      <CurrentPageLink /> {/* 現在ページのリンク */}
      <NextPageLink /> {/* 現在ページの一つ次のリンク */}
      <NextEllipsis /> {/* 右側の...の表示 */}
      <LastPageLink /> {/* 右端のリンク */}
    </>
  );
};

// 省略

ロジックに if 文を使ってしまうと可読性が悪くなるので条件分岐を 1 つに抑え、三項演算子で済むように整えています。条件が合わない場合は <Void> というコンポーネントを返します。こうすることで最後に filter などして整理する手間を省いています。

Pagination 全体のロジック

<Pagination /> 本体の定義もこのファイルにあります。この中には今説明した lineUpPageLinks を呼んでいる箇所があります。加えてその両サイドに設置する <PreviousPageLink /><NextPageLink /> を表示するロジックを持っています。

src/Pagination/Pagination.tsx
const Pagination = ({
  total,
  limit,
  currentPage,
  onSelectPage
}: PaginationProps) => {

  // 省略 ここには入力値のバリデーションと pageLink の準備が書かれている

  const PreviousPageLink = pageLink({ page: currentPage - 1, label: "◀" });
  const NextPageLink = pageLink({ page: currentPage + 1, label: "▶" });
  const PageLinks = lineUpPageLinks({
    lastPage,
    currentPage,
    PageLink
  });

  return (
    <nav
      role="navigation"
      aria-label="pagination"
      style={{
        display: "flex",
        gap: ".5rem"
      }}
    >
      {!currentPageIsFirst && <PreviousPageLink />} // 現在のページが最初であれば表示しない
      <PageLinks />
      {!currentPageIsLast && <NextPageLink />} // 現在のページが最後であれば表示しない
    </nav>
  );
};

さて、次は HOC として実装している pageLink について説明していきます。

page-link.tsx

<button> を使った関数コンポーネントを生成する高階コンポーネント baseLink を定義しています。

このファイルが Capital Camelcase になっていないのは、export しているのが関数コンポーネント (() => JSX.Element) ではなく高階コンポーネントだからです。少し複雑ですが、export しているのは 2 重の高階コンポーネント (() => () => () => JSX.Element) になります。

src/Pagination/page-link.tsx
type BasePageLinkProps = {
  currentPage: number;
  onSelectPage: (pageNumber: number) => void;
};

type PageLinkProps = {
  page: number;
  label?: string;
};

const basePageLink = ({ currentPage, onSelectPage }: BasePageLinkProps) => {
  const pageLink = ({ page, label }: PageLinkProps) => {
    const isCurrent = page === currentPage;
    return () => (
      <button
        // disabled={isCurrent}
        onClick={(event) => {
          event.preventDefault();
          onSelectPage(page);
        }}
        style={{
          cursor: "pointer",
          pointerEvents: isCurrent ? "none" : "auto",
          borderStyle: isCurrent ? "none" : ""
        }}
      >
        {label ?? page} // label の指定がなければページ番号を表示する
      </button>
    );
  };
  return pageLink;
};

高階関数 basePageLink が受け取るのは全てのボタンに必要な共通情報である currentPageonSelectPage です。これらを与えることにより高階コンポーネント pageLink を生成します。 pageLink はページ番号(page)とラベル(label)を受け取り、関数コンポーネント PageLink を返します。

Ellipsis.tsx

を表示するだけのコンポーネントです。PageLink とは勝手が異なるため、個別定義しています。

まとめ

高階コンポーネントで共通部分をいかにまとめるかというのは React の面白いところです。今回はかなりシンプルな実装ですが、Context を内包した高階コンポーネントなどを作れるようになると、複雑なロジックをシンプルな記述で表現できるため非常に強力です。

GitHubで編集を提案

Discussion

ログインするとコメントできます