RIT Tech Blog

株式会社RITのエンジニアが知見を共有する技術ブログです。

PagyとReact-Bootstrapでページネーションを実装する

こんにちは!RITエンジニアの崎田です。

今回は、Rails & Next.js環境下でのPagyとReact-BootstrapのPaginationコンポーネントを使用した、ページネーションの実装方法を解説していきたいと思います。

使用環境

  • Ruby on Rails (6.1.4.6)
  • Next.js (12.1.0)  ※使用言語はTypeScript

完成イメージ

ezgif-2-ebad878b0a31.gif (206.1 kB)

ページネーションの実装に入る前の準備

ページネーションの実装に入る前に、準備としてユーザー一覧表を作っておきます。

バックエンド側 (Ruby on Rails) の準備

RailsアプリをAPIモードで作成します。

rails new pagination-sample-api --api

Railsアプリ内のディレクトリに移動します

cd pagination-sample-api

③ ユーザーのコントローラーとモデルをscaffoldで一気に作成してマイグレーションします。

rails g scaffold user name:string
rails db:migrate

⑤ ユーザー100件分のシードデータを設定してDBに追加します。

# db/seeds.rb

100.times do |n|
  User.where(id: n + 1).first_or_create!(name: "test #{n + 1}")
end
rails db:seed

⑥ rack-corsを導入してCORSの設定します。

# Gemfile

gem 'rack-cors'
bundle
# config/initializers/cors.rb

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'localhost:8080'

    resource '*',
             headers: :any,
             methods: %i[get post put patch delete options head]
  end
end

⑦ users_controllerの不要なアクションなどは全て消しちゃいましょう。

# app/controllers/users_controller.rb

class UsersController < ApplicationController
  def index
    @users = User.all

    render json: @users
  end
end

rails sでサーバーを立ち上げてhttp://localhost:3000/usersにアクセスして以下のように100件分のユーザー情報が表示されたら成功です! スクリーンショット 2021-10-07 21.46.49.png (201.2 kB)

これでバックエンド側の準備ができました🎊

 

フロントエンド側 (Next.js) の準備

① Next.jsのプロジェクトをTypeScriptオプションで作成します。

yarn create next-app --typescript

② プロジェクト名の入力を求められるのでpagination-sample-clientと入力しましょう。 スクリーンショット 2021-10-07 21.42.33.png (30.2 kB)

③ Next.jsアプリのディレクトリに移動します

cd pagination-sample-client

yarn devでサーバーを立ち上げる際のポートを8080に設定します。

// package.json

{
  // 省略
  "scripts": {
    "dev": "next dev -p 8080",
    // 省略
  },
  // 省略
}

⑤ 実装に必要なパッケージをそれぞれ追加します。

yarn add bootstrap@5.1 
yarn add react-bootstrap
yarn add sass
yarn add axios

⑥ 表示部分を実装するために_app.tsxpages/index.tsxを以下の内容に書き換えます。

// pages/_app.tsx

import type { AppProps } from 'next/app';
import '../node_modules/bootstrap/scss/bootstrap.scss';

function MyApp({ Component, pageProps }: AppProps) {
    return <Component {...pageProps} />;
}
export default MyApp;
// pages/index.tsx

import type { NextPage } from 'next';
import Router from 'next/router';
import { useEffect } from 'react';

const Home: NextPage = () => {
    useEffect(() => {
        Router.push('/users');
    }, []);
    
    return null;
};

export default Home;

また、pages配下にusersフォルダを作成してその中にindex.tsxを作成して以下のように記述してください。

// pages/users/index.tsx

import type { NextPage } from 'next';
import React, { useEffect, useState } from 'react';
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import { Container, Table } from 'react-bootstrap';

class RestClient {
    async get<ResponseInterface>(
        path: string,
    ): Promise<AxiosResponse<ResponseInterface>> {
        return await axios.get<ResponseInterface>(path, this.requestConfig);
    }

    private get requestConfig(): AxiosRequestConfig {
        return {
            baseURL: 'http://localhost:3000',
            headers: {
                'Content-Type': 'application/json',
                Authorization: '',
            },
        };
    }
}

class User {
    constructor(
        public id: number,
        public name: string,
        public createdAt: Date,
        public updatedAt: Date,
    ) {}
}

interface UserResponseObject {
    id: number;
    name: string;
    created_at: string;
    updated_at: string;
}

const UsersIndex: NextPage = () => {
    const [users, setUsers] = useState<User[]>([]);

    const fetchData = async () => {
        const getNoAuthClient = () => new RestClient();
        const res = await getNoAuthClient().get<UserResponseObject[]>(
            `/users`,
        );
        const users = (res: UserResponseObject) =>
            new User(
                res.id,
                res.name,
                new Date(res.created_at),
                new Date(res.updated_at),
            );
        setUsers(res.data.map(users));
    };

    useEffect(() => {
        fetchData();
    }, []);

    const trs = users.map(user => {
        return (
            <tr key={user.id}>
                <td style={{ width: '5%' }}>{user.id}</td>
                <td>{user.name}</td>
            </tr>
        );
    });

    return (
        <Container className='mx-auto my-5'>
            <Table striped bordered hover>
                <thead>
                    <tr>
                        <th>ID</th>
                        <th>Name</th>
                    </tr>
                </thead>
                <tbody>{trs}</tbody>
            </Table>
        </Container>
    );
};

export default UsersIndex;

yarn devでサーバーを立ち上げてhttp://localhost:8080/usersにアクセスして以下の画面が表示されたら成功です!(100件目まで表示されます) スクリーンショット 2021-10-12 10.23.11.png (46.7 kB)

※もしエラーが表示されたらバックエンド側のサーバーがちゃんと立ち上がっているか確認してみてください。それでもエラーが出る場合は.nextフォルダを消してからサーバーを立ち上げ直してみてください。

これでフロントエンド側の準備もできました🎊

 

ユーザー一覧画面にページネーションを実装する

ここからが本題であるページネーションの実装になります。

① バックエンド(Ruby 0n Rails)側でPagyを用いてページごとにユーザー情報を返す処理を実装する

Pagyのgemを追加します。

# Gemfile

gem 'pagy'
bundle

configフォルダ内のinitializerフォルダにpagy.rbを作成して、以下のように10件ずつ返すように設定します。

# config/initializers/pagy.rb

Pagy::DEFAULT[:items] = 10

cors.rbでヘッダーに総ページ情報を含めるように設定します。

# config/initializers/cors.rb

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'localhost:8080'

    # ここから
    resource '*',
             headers: :any,
             methods: %i[get post put patch delete options head],
             expose: %w[Total-Pages]
    # ここまで
  end
end

users_controller.rbの内容をPagyによるユーザー情報と総ページ数を返す処理に変更します。

# app/controllers/users_controller.rb

class UsersController < ApplicationController
  # ここから
  include Pagy::Backend

  def index
    pagy, @users = pagy(User.all)
    response.headers['Total-Pages'] = pagy.pages

    render json: @users
  end
  # ここまで
end

② フロントエンド側(Next.js)でページごとにユーザー情報を取得する処理に変更する

以下のファイルを、ページごとにユーザー情報を取得をリクエストする処理に書き換えます。

// pages/users/index.tsx

// ここから
import type { NextPage, GetServerSideProps } from 'next';
// ここまで
import React, { useEffect, useState } from 'react';
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import { Container, Table } from 'react-bootstrap';

class RestClient {
    async get<ResponseInterface>(
        path: string,
    ): Promise<AxiosResponse<ResponseInterface>> {
        return await axios.get<ResponseInterface>(path, this.requestConfig);
    }

    private get requestConfig(): AxiosRequestConfig {
        return {
            baseURL: 'http://localhost:3000',
            headers: {
                'Content-Type': 'application/json',
                Authorization: '',
            },
        };
    }
}

class User {
    constructor(
        public id: number,
        public name: string,
        public createdAt: Date,
        public updatedAt: Date,
    ) {}
}

interface UserResponseObject {
    id: number;
    name: string;
    created_at: string;
    updated_at: string;
}

// ここから
interface Props {
    currentPage: number;
}

const UsersIndex: NextPage<Props> = ({ currentPage }) => {
    const [users, setUsers] = useState<User[]>([]);
    const [totalPages, setTotalPages] = useState<number>();

    const fetchData = async () => {
        const getNoAuthClient = () => new RestClient();
        const res = await getNoAuthClient().get<UserResponseObject[]>(
            `/users`,
        );
        const users = (res: UserResponseObject) =>
            new User(
                res.id,
                res.name,
                new Date(res.created_at),
                new Date(res.updated_at),
            );
        setUsers(res.data.map(users));
        setTotalPages(Number(res.headers['total-pages']));
    };

    useEffect(() => {
        fetchData();
    }, [currentPage]);
    
    if (!totalPages) return null;

    const trs = users.map(user => {
        return (
            <tr key={user.id}>
                <td style={{ width: '5%' }}>{user.id}</td>
                <td>{user.name}</td>
            </tr>
        );
    });

    return (
        <Container className='mx-auto my-5'>
            <Table striped bordered hover>
                <thead>
                    <tr>
                        <th>ID</th>
                        <th>Name</th>
                    </tr>
                </thead>
                <tbody>{trs}</tbody>
            </Table>
        </Container>
    );
};

export const getServerSideProps: GetServerSideProps = async ctx => {
    const currentPage = Number(ctx.query.page) || 1;
    return { props: { currentPage } };
};
// ここまで

export default UsersIndex;

③ フロントエンド側(Next.js)でReact-BootstrapのPaginationコンポーネントを用いてページネーション部分の処理をまとめたコンポーネントを作成する

トップの階層にcomponentsフォルダを作成します。その中にページネーションのコンポーネントファイルをPaginationParts.tsxという名前で作成して以下のように記述します。

// components/PaginationParts.tsx

import React from 'react';
import { Pagination } from 'react-bootstrap';
import Router from 'next/router';

interface Props {
    // 総ページ数
    totalPages: number;
    // 現在のページ
    currentPage: number;
    // 遷移先のクエリに渡すページ数以外の文字列
    targetPagePath: string;
}

// ページネーションの数字ボタンの数
const totalPageIndications = 5;
// ページネーションの数字ボタンの真ん中の位置 (例: 5の場合は3, 10の場合は6)
const middlePagePosition = Math.floor(totalPageIndications / 2) + 1;

export const PaginationParts: React.FC<Props> = ({totalPages,currentPage,targetPagePath}) => {
    const totalItems =
        totalPages > totalPageIndications
            ? [...Array(totalPageIndications)]
            : [...Array(totalPages)];
    const paginationItems = totalItems.map((e, key) => {
        const pageNumber = key + 1;
        const pageNumberAroundMiddlePage =
            pageNumber + currentPage - middlePagePosition;
        const pageNumberUpToLastPage =
            pageNumber + totalPages - totalPageIndications;
        if (
            currentPage <= middlePagePosition ||
            totalPages <= totalPageIndications
        ) {
            return (
                <Pagination.Item
                    key={pageNumber}
                    onClick={() =>
                        Router.push(targetPagePath + pageNumber)
                    }
                    active={pageNumber === currentPage}
                    className='mx-1'
                >
                    {pageNumber}
                </Pagination.Item>
            );
        } else if (
            currentPage + middlePagePosition - 1 >
            totalPages
        ) {
            return (
                <Pagination.Item
                    key={pageNumberUpToLastPage}
                    onClick={() =>
                        Router.push(
                            targetPagePath + pageNumberUpToLastPage,
                        )
                    }
                    active={pageNumberUpToLastPage === currentPage}
                    className='mx-1'
                >
                    {pageNumberUpToLastPage}
                </Pagination.Item>
            );
        } else {
            return (
                <Pagination.Item
                    key={pageNumberAroundMiddlePage}
                    onClick={() =>
                        Router.push(
                            targetPagePath + pageNumberAroundMiddlePage,
                        )
                    }
                    active={pageNumberAroundMiddlePage === currentPage}
                    className='mx-1'
                >
                    {pageNumberAroundMiddlePage}
                </Pagination.Item>
            );
        }
    });

    return (
        <Pagination className='align-items-center'>
            <Pagination.First
                onClick={() =>
                    Router.push(targetPagePath + 1)
                }
                className='mx-1'
                disabled={currentPage === 1}
            />
            <Pagination.Prev
                onClick={() =>
                    Router.push(targetPagePath + (currentPage - 1))
                }
                className='mx-1'
                disabled={currentPage === 1}
            />
            {paginationItems}
            <Pagination.Next
                onClick={() =>
                    Router.push(targetPagePath + (currentPage + 1))
                }
                className='mx-1'
                disabled={currentPage === totalPages}
            />
            <Pagination.Last
                onClick={() =>
                    Router.push(targetPagePath + totalPages)
                }
                className='mx-1'
                disabled={currentPage === totalPages}
            />
        </Pagination>
    );
};

 

★ 今回作成したPaginationPartsコンポーネントの内容について説明する前に、React-Bootstrapが用意しているページネーション用のコンポーネントについてそれぞれ簡単に説明していきたいと思います。

Pagination
全てのページネーションコンポーネントをこのコンポーネントでラップします。

Pagination.First, Pagination.Prev, Pagination.Next, Pagination.Last
それぞれ最初のページ・前のページ・次のページ・最後のページに遷移するボタンとして使用するコンポーネントです。 disableプロップスがtrueだと無効表示になります。 hrefプロップスやonClickプロップスで遷移先を設定します。

Pagination.Item
ページ番号を表示してそのページに遷移するボタンとして使用するコンポーネントです。 activeプロップスがtrueだとハイライト表示になります。 上のコンポーネントと同様にhrefプロップスやonClickプロップスで遷移先を設定します。

参考: https://react-bootstrap.github.io/components/pagination/  

Pagination.Itemの表示方法ですが、ロジックが少しわかりにくいので実際に表示する部分を箱に例えて説明します。

ブログ用図解 pagination sample.002.png (326.0 kB)

 

まず以下の3つの箱を用意して、条件によって表示を出し分けるようにします。
  1. 最初の方のページボタン群を表示する箱
  2. 最後の方のページボタン群を表示する箱
  3. 中間のページボタン群を表示する箱  


『1』の箱については、『現在のページが箱の真ん中の位置*以下、もしくは総ページ数が箱のボタンの総数以下の場合』に表示します。

実際に表示されるのは最初のページのボタンから箱の最後のページのボタン(または最後のページのボタン)までになります。

※「箱の真ん中の位置」というのはボタンの総数(今回の場合は5)から見て真ん中という意味なので3になります ブログ用図解 pagination sample.004.png (65.0 kB)

『2』の箱については、『現在のページと箱の真ん中の位置の合計から1引いた数が総ページ数より大きい場合』に表示します。

実際に表示されるのは最後のページに1を足した数から箱のボタンの総数を引いたページのボタンから最後のページのボタンまでになります。 ブログ用図解 pagination sample.005.png (82.4 kB)

『3』の箱については、1, 2のどちらでもない場合に表示します。

実際に表示されるのは現在のページから箱の真ん中の位置を引いた数のボタンの次のボタン(ややこしいので下の図を参考にして下さい)から箱の最後のページのボタンまでになります。3の箱の条件内でページが切り替わる場合は、現在のページのボタンを常に真ん中に表示したまま箱自体が動いていくような感じになります。 ブログ用図解 pagination sample.006.png (118.9 kB)

これら3つの箱を現在のページの位置に応じて表示を分けています。

※ 今回の場合だと1〜3ページ目は『1』の箱、4〜8ページ目は『3』の箱、9〜10ページ目は『2』の箱で表示しています。

④ 最後にユーザー一覧画面の下部にページネーションの処理をまとめたPaginationPartsコンポーネントを設置する

pages配下のusersフォルダ内のindex.tsxを以下の内容に変更します。

// pages/users/index.tsx

import type { NextPage, GetServerSideProps } from 'next';
import React, { useEffect, useState } from 'react';
// ここから
import { PaginationParts } from '../../components/PaginationParts';
// ここまで
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import { Container, Table } from 'react-bootstrap';

class RestClient {
    async get<ResponseInterface>(
        path: string,
    ): Promise<AxiosResponse<ResponseInterface>> {
        return await axios.get<ResponseInterface>(path, this.requestConfig);
    }

    private get requestConfig(): AxiosRequestConfig {
        return {
            baseURL: 'http://localhost:3000',
            headers: {
                'Content-Type': 'application/json',
                Authorization: '',
            },
        };
    }
}

class User {
    constructor(
        public id: number,
        public name: string,
        public createdAt: Date,
        public updatedAt: Date,
    ) {}
}

interface UserResponseObject {
    id: number;
    name: string;
    created_at: string;
    updated_at: string;
}

interface Props {
    currentPage: number;
}

const UsersIndex: NextPage<Props> = ({ currentPage }) => {
    const [users, setUsers] = useState<User[]>([]);
    const [totalPages, setTotalPages] = useState<number>();


    const fetchData = async () => {
        const getNoAuthClient = () => new RestClient();
        const res = await getNoAuthClient().get<UserResponseObject[]>(
            `/users`,
        );
        const users = (res: UserResponseObject) =>
            new User(
                res.id,
                res.name,
                new Date(res.created_at),
                new Date(res.updated_at),
            );
        setUsers(res.data.map(users));
        setTotalPages(Number(res.headers['total-pages']));
    };

    useEffect(() => {
        fetchData();
    }, [currentPage]);
    
    if (!totalPages) return null;

    const trs = users.map(user => {
        return (
            <tr key={user.id}>
                <td style={{ width: '5%' }}>{user.id}</td>
                <td>{user.name}</td>
            </tr>
        );
    });

    return (
        <Container className='mx-auto my-5'>
            <Table striped bordered hover>
                <thead>
                    <tr>
                        <th>ID</th>
                        <th>Name</th>
                    </tr>
                </thead>
                <tbody>{trs}</tbody>
            </Table>
            {/* ここから */}
            <Container className='d-flex justify-content-center mt-5'>
                <PaginationParts
                    totalPages={totalPages}
                    currentPage={currentPage}
                    targetPagePath={`?page=`}
                />
            </Container>
            {/* ここまで */}
        </Container>
    );
};

export const getServerSideProps: GetServerSideProps = async ctx => {
    const currentPage = Number(ctx.query.page) || 1;
    return { props: { currentPage } };
};

export default UsersIndex;

これで全てのページネーションの実装ができました!

PaginationPartsコンポーネントの各プロップスに、『総ページ数』と『現在のページ』と『遷移先のパスのクエリに渡すページ数以外の文字列』がそれぞれ設定されているのがわかると思います。

終わりに

長くなってしまいましたが、以上がPagyとReact-Bootstrapを使ったページネーションの実装方法になります。

最後までご覧いただきありがとうございました!🙇‍♂️