こんにちは!RITエンジニアの崎田です。
今回は、Rails & Next.js環境下でのPagyとReact-BootstrapのPaginationコンポーネントを使用した、ページネーションの実装方法を解説していきたいと思います。
使用環境
完成イメージ
ページネーションの実装に入る前の準備
ページネーションの実装に入る前に、準備としてユーザー一覧表を作っておきます。
① 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に追加します。
100.times do |n|
User.where(id: n + 1).first_or_create!(name: "test #{n + 1}")
end
rails db:seed
⑥ rack-corsを導入してCORSの設定します。
gem 'rack-cors'
bundle
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の不要なアクションなどは全て消しちゃいましょう。
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
に設定します。
{
"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
を以下の内容に書き換えます。
import type { AppProps } from 'next/app';
import '../node_modules/bootstrap/scss/bootstrap.scss';
function MyApp({ Component, pageProps }: AppProps) {
return <Component {...pageProps} />;
}
export default MyApp;
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
を作成して以下のように記述してください。
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を追加します。
gem 'pagy'
bundle
configフォルダ内のinitializerフォルダにpagy.rb
を作成して、以下のように10件ずつ返すように設定します。
Pagy::DEFAULT[:items] = 10
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によるユーザー情報と総ページ数を返す処理に変更します。
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)でページごとにユーザー情報を取得する処理に変更する
以下のファイルを、ページごとにユーザー情報を取得をリクエストする処理に書き換えます。
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
という名前で作成して以下のように記述します。
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;
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
を以下の内容に変更します。
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を使ったページネーションの実装方法になります。
最後までご覧いただきありがとうございました!🙇♂️