at backyard

Color my life with the chaos of trouble.

PaginationをReactで実装したサンプル (Bootstrap使用)

去年書いていたポストだが、下書きの状態のママになっていたため、公開することにした。
以下本文。


最近Reactで実装されたPaginationに触れる機会があったので、自分の理解を深める意味も込めて、自分なりに1から実装してみる。 といっても、スタイルはbootstrapを用いて、bootstrapのページ内にあるpaginationの実装を参考に実装してみる。

コードはgithubにアップしている

GitHub - shinshin86/pagination-sample-at-react-with-bootstrap

まずはcreate-react-appでセットアップ

以下、備忘録。 アプリの雛形は create-react-app を用いる。

npx create-react-app pagination-sample-at-react-with-bootstrap
cd pagination-sample-at-react-with-bootstrap

ちなみにcreate-react-appを使う際の個人的な好みなのだが、yarn startした際にブラウザが勝手に立ち上がらないようにしたいので、下記の変更を行う。

https://shinshin86.hateblo.jp/entry/2019/09/28/084835

diff --git a/package.json b/package.json
index 52a2f12..0730227 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
     "react-scripts": "3.1.2"
   },
   "scripts": {
-    "start": "react-scripts start",
+    "start": "BROWSER=none react-scripts start",
     "build": "react-scripts build",
     "test": "react-scripts test",
     "eject": "react-scripts eject"

Bootstrapのセットアップと開発準備

public/index.htmlにbootstrapを使うのに必要な設定を記述しておく

Introduction · Bootstrap

src/app.jsを一度下記のようにして実装準備を実施

import React from 'react';
import './App.css';

export default function() {
  return (
    <div className="App">
      <h1>Pagination Sample</h1>
    </div>
  );
}

Bootstrapを使ってReact内でpaginationを実装していく

参考にするのは下記のドキュメント

Pagination · Bootstrap

実際の実装はgithubに上がっているので、ここでは主要なソースコードだけ記述していく

Paginationサンプル(全データ取得版)

まずはテスト用のテストデータを返すサンプル。ユーザデータの配列を返す。

const sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
export const getTestData = async () => {
  await sleep(1000);

  return [
    { id: 1, name: "Test1", isAdmin: true },
    { id: 2, name: "Test2", isAdmin: false },
    ・
    ・
    ・
    { id: 100, name: "Test100", isAdmin: false }
  ];
};

つぎは src.App.js
表示判定の処理がもうちょっと良く出来そうだが、このあと書き換えるので、一旦はこれで。
PaginationComponent内でページネーションに関する実装は書かれている。

import React, { useState, useEffect } from "react";
import { getTestData } from "./test-data";
import "./App.css";
import Pagination from "./Pagination";

export default function() {
  const [isFetching, setIsFetching] = useState(false);
  const [userList, setUsetList] = useState([]);
  const [pageState, setPageState] = useState({
    currentPage: 1,
    totalPage: 0,
    maxPerPage: 20
  });

  useEffect(() => {
    const fetchData = async () => {
      setIsFetching(true);

      await getTestData();
      const data = await getTestData();
      setUsetList(data);
      setIsFetching(false);
      const totalPage = Math.ceil(data.length / pageState.maxPerPage);
      setPageState(Object.assign({ ...pageState }, { totalPage }));
    };

    fetchData();
  }, []);

  if (isFetching) return <div>Loading...</div>;

  return (
    <div className="App">
      <h1>Pagination Sample</h1>
      <div className="container">
        <table className="table">
          <thead>
            <tr>
              <th scope="col">ID</th>
              <th scope="col">Name</th>
              <th scope="col">Admin</th>
            </tr>
          </thead>
          <tbody>
            {userList.map((user, index) => {
              const dataRangeMin =
                +pageState.maxPerPage * (pageState.currentPage - 1);
              const dataRangeMax =
                +pageState.maxPerPage * pageState.currentPage;
              return (
                dataRangeMin <= index &&
                dataRangeMax > index && (
                  <tr>
                    <th scope="row">{user.id}</th>
                    <td>{user.name}</td>
                    <td>{user.isAdmin ? "Yes" : "No"}</td>
                  </tr>
                )
              );
            })}
          </tbody>
        </table>
        <Pagination pageState={pageState} setPageState={setPageState} />
      </div>
    </div>
  );
}

src/Pagination.js

import React from "react";

export default ({ pageState, setPageState }) => {
  const { currentPage, totalPage, maxPerPage } = pageState;
  return (
    <nav aria-label="Page navigation">
      <ul className="pagination">
        <li className={+currentPage === 1 ? "page-item disabled" : "page-item"}>
          <a
            className="page-link"
            onClick={() => {
              const nextPageNumber = +currentPage - 1;
              setPageState(
                Object.assign({ ...pageState }, { currentPage: nextPageNumber })
              );
            }}
          >
            Previous
          </a>
        </li>
        {Array.from(new Array(totalPage)).map((v, i) => {
          const pageNumber = ++i;
          return (
            <li
              className={
                currentPage === pageNumber ? "page-item active" : "page-item"
              }
            >
              <a
                className="page-link"
                onClick={() =>
                  setPageState(
                    Object.assign({ ...pageState }, { currentPage: i })
                  )
                }
              >
                {pageNumber}
              </a>
            </li>
          );
        })}
        <li
          className={
            +currentPage === +totalPage ? "page-item disabled" : "page-item"
          }
        >
          <a
            className="page-link"
            onClick={() => {
              const nextPageNumber = +currentPage + 1;
              setPageState(
                Object.assign({ ...pageState }, { currentPage: nextPageNumber })
              );
            }}
          >
            Next
          </a>
        </li>
      </ul>
    </nav>
  );
};

この実装の場合、一度画面側にすべてのデータをFetchし、ローカルのstateに全て入れたあとに、それをフィルタリングすることでpagination的な動きを実現させている。
fetchしてくるデータ量がそこまで多くならない場合、この実装でもそれほどパフォーマンスには影響が出ないかと思う。
ただ、データ量が増えた場合、これではうまくいかなくなる。
選択したページに応じたデータの範囲のみを取得して表示、というのが安全だ。

というわけで、指定したページに応じた範囲のデータを取得し表示するサンプルを書いていく。

Paginationサンプル(指定したページに対応したデータのみを返す)

まずはテストデータを返すサンプルを、指定した範囲だけ返すような実装に書き換えた。
optionにoffsetを指定することで返すデータの範囲を指定できるようにしている。

const sleep = msec => new Promise(resolve => setTimeout(resolve, msec));
export const getTestData = async (option = {}) => {
  const { limit = 20, offset = 0 } = option;
  await sleep(1000);

  const rangeMin = offset;
  const rangeMax = offset + limit;
  const userCount = dataList.length;
  const userList = dataList.filter(data => {
    if (rangeMin <= data.id && rangeMax > data.id) return data;
  });

  return { userList, userCount };
};

const dataList = [
  { id: 1, name: "Test1", isAdmin: true },
  { id: 2, name: "Test2", isAdmin: false },
  ・
  ・
  ・
  { id: 100, name: "Test100", isAdmin: false }
];

次に src/App.js
handleClickPaginationという関数を用意して、paginationクリック時に再度データを取得するようにしている。
また、取得データに関するフィルター処理がなくなっており、コード自体も見やすくなった。

import React, { useState, useEffect } from "react";
import { getTestData } from "./test-data";
import "./App.css";
import Pagination from "./Pagination";

export default function() {
  const [isFetching, setIsFetching] = useState(false);
  const [userList, setUsetList] = useState([]);
  const [pageState, setPageState] = useState({
    currentPage: 1,
    totalPage: 0,
    maxPerPage: 20
  });

  useEffect(() => {
    const fetchData = async () => {
      setIsFetching(true);

      // Fetch data
      await getTestData();
      const offset = (pageState.currentPage - 1) * pageState.maxPerPage;
      const { userList, userCount } = await getTestData({ offset });
      setUsetList(userList);
      setIsFetching(false);

      // Update pagination state
      const totalPage = Math.ceil(userCount / pageState.maxPerPage);
      setPageState(Object.assign({ ...pageState }, { totalPage }));
    };

    fetchData();
  }, []);

  const handleClickPagination = async nextPageNumber => {
    setIsFetching(true);

    // Fetch data
    const offset = (nextPageNumber - 1) * pageState.maxPerPage;
    const { userList, userCount } = await getTestData({ offset });
    setUsetList(userList);
    setIsFetching(false);

    // Updata pagination state
    const totalPage = Math.ceil(userCount / pageState.maxPerPage);
    setPageState(
      Object.assign(
        { ...pageState },
        { totalPage, currentPage: nextPageNumber }
      )
    );
  };

  if (isFetching) return <div>Loading...</div>;

  return (
    <div className="App">
      <h1>Pagination Sample</h1>
      <div className="container">
        <table className="table">
          <thead>
            <tr>
              <th scope="col">ID</th>
              <th scope="col">Name</th>
              <th scope="col">Admin</th>
            </tr>
          </thead>
          <tbody>
            {userList.map(user => (
              <tr>
                <th scope="row">{user.id}</th>
                <td>{user.name}</td>
                <td>{user.isAdmin ? "Yes" : "No"}</td>
              </tr>
            ))}
          </tbody>
        </table>
        <Pagination
          pageState={pageState}
          handleClickPagination={handleClickPagination}
        />
      </div>
    </div>
  );
}

最後にPagination
こちらはあまり変わらない。基本的には指定したページを関数に入れるようにしたぐらいだろうか。
もうちょっと整えることできそうだが、そこらへんは一旦割愛する。

import React from "react";

export default ({ pageState, handleClickPagination }) => {
  const { currentPage, totalPage } = pageState;
  return (
    <nav aria-label="Page navigation">
      <ul className="pagination">
        <li className={+currentPage === 1 ? "page-item disabled" : "page-item"}>
          <a
            className="page-link"
            onClick={() => {
              const nextPageNumber = +currentPage - 1;
              handleClickPagination(nextPageNumber);
            }}
          >
            Previous
          </a>
        </li>
        {Array.from(new Array(totalPage)).map((v, i) => {
          const pageNumber = ++i;
          return (
            <li
              className={
                currentPage === pageNumber ? "page-item active" : "page-item"
              }
            >
              <a className="page-link" onClick={() => handleClickPagination(i)}>
                {pageNumber}
              </a>
            </li>
          );
        })}
        <li
          className={
            +currentPage === +totalPage ? "page-item disabled" : "page-item"
          }
        >
          <a
            className="page-link"
            onClick={() => {
              const nextPageNumber = +currentPage + 1;
              handleClickPagination(nextPageNumber);
            }}
          >
            Next
          </a>
        </li>
      </ul>
    </nav>
  );
};

この実装の場合、ページを変更するたびにデータ取得処理が走り、指定した範囲のデータのみをstateで保持するようになる。
データ量が増えても、これであればパフォーマンスには影響はないかと思われる。

なお、これだと例えば2ページ目のURLを誰かに共有ということが出来ない。
ページネーションを行う際に、よくある ?page=2 的なqueryをURLにつけたいところだが、思いの外文章が長くなってしまったのでそこはまた別の機会に試そうと思う。