こんにちは!RITエンジニアの崎田です。
今回は、Rails & Next.js環境下でのPagyとReact-BootstrapのPaginationコンポーネントを使用した、ページネーションの実装方法を解説していきたいと思います。
使用環境
- Ruby on Rails (6.1.4.6)
- Next.js (12.1.0) ※使用言語はTypeScript
完成イメージ
ページネーションの実装に入る前の準備
ページネーションの実装に入る前に、準備としてユーザー一覧表を作っておきます。
バックエンド側 (Ruby on Rails) の準備
rails new pagination-sample-api --api
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件分のユーザー情報が表示されたら成功です!
これでバックエンド側の準備ができました🎊
フロントエンド側 (Next.js) の準備
① Next.jsのプロジェクトをTypeScriptオプションで作成します。
yarn create next-app --typescript
② プロジェクト名の入力を求められるのでpagination-sample-client
と入力しましょう。
③ 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.tsx
とpages/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件目まで表示されます)
※もしエラーが表示されたらバックエンド側のサーバーがちゃんと立ち上がっているか確認してみてください。それでもエラーが出る場合は.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
の表示方法ですが、ロジックが少しわかりにくいので実際に表示する部分を箱に例えて説明します。
まず以下の3つの箱を用意して、条件によって表示を出し分けるようにします。
- 最初の方のページボタン群を表示する箱
- 最後の方のページボタン群を表示する箱
- 中間のページボタン群を表示する箱
『1』の箱については、『現在のページが箱の真ん中の位置*以下、もしくは総ページ数が箱のボタンの総数以下の場合』に表示します。
実際に表示されるのは最初のページのボタンから箱の最後のページのボタン(または最後のページのボタン)までになります。
※「箱の真ん中の位置」というのはボタンの総数(今回の場合は5)から見て真ん中という意味なので3になります
『2』の箱については、『現在のページと箱の真ん中の位置の合計から1引いた数が総ページ数より大きい場合』に表示します。
実際に表示されるのは最後のページに1を足した数から箱のボタンの総数を引いたページのボタンから最後のページのボタンまでになります。
『3』の箱については、1, 2のどちらでもない場合に表示します。
実際に表示されるのは現在のページから箱の真ん中の位置を引いた数のボタンの次のボタン(ややこしいので下の図を参考にして下さい)から箱の最後のページのボタンまでになります。3の箱の条件内でページが切り替わる場合は、現在のページのボタンを常に真ん中に表示したまま箱自体が動いていくような感じになります。
これら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を使ったページネーションの実装方法になります。
最後までご覧いただきありがとうございました!🙇♂️