kaminariのviewをReactで書いてみた

最近Reactでの開発タスクが増大中のWebデザイナーです。
フロントエンドエンジニアではありません。 **Webデザイナー**です。

つい先日、とあるページを [React](https://reactjs.org/) + [Redux](https://redux.js.org/) で書き直しました。
今回は、その際に **ページ送り(pagination)もReactでレンダリングするようにしたよ** という話です。

![pagination.png](https://qiita-image-store.s3.amazonaws.com/0/53286/4f4a6ccb-70dc-e20c-32d5-8f3acf818034.png)
↑
こういうやつです。

# なぜReactでレンダリングしたの??

Railsアプリケーションでgemの[kaminari](https://github.com/kaminari/kaminari)を導入していて、ページネーションにはkaminariを使用していました。
普通に使う分にはkaminariのviewにスタイルつけて `= paginate @hogehoge` みたいにviewで呼び出せばいいのですが、今回は以下の事情でReactで描画するようにしました。

1. タグやエリア、期間のような条件での絞り込みが発生するため、ページ全体をReact + Reduxでステート管理できるようにしたかった
2. そのため、ページ送りも含めなければいけなかった
3. 都度、ページ全体を読み込むのではなく、jsだけの読み込みにして速度改善したかった

実装してみると、特に3の効果が絶大で、絞り込みや条件の追加、ページ送りなどをした時の読み込みが超速になりました。何秒速くなったのかは計測していないですが、実装前を知っている人からは感嘆の声が漏れてきました。

# Reactで書いてみよう

kaminariから以下の値をReactコンポーネントに渡してあげる必要があります。

- current_page: 現在のページ
- total_pages: 総ページ数

また、今回はやりませんが、limit_value(ページあたりの表示件数)とtotal_count(合計件数)も渡してやれば、**何件中何件を表示しています**を出すことも可能です。
ReactではこれらをPropsとして受け取り、以下のコンポーネントを作成していきます。

- Pagination
    - FirstPaginationItem: 最初のページ
    - LastPaginationItem: 最後のページ
    - PrevPaginationItem: 1つ戻る
    - NextPaginationItem: 1つ進む
    - Gap: `...` で表示される
    - PaginationItem: 該当ページへのリンク

※ 最後に全体のコードを載せています

## PaginationItemを作成しよう

ページネーションのHTML構成はリストタグにしたいので、これから作る`PaginationItem`は`<li></li>`で出力されるようにします。

```jsx:最初のページ
const FirstPaginationItem = ({ onClick }) => (
  <li className="pagination-item">
    <button type="button" onClick={() => onClick(1)}>
      &laquo;
    </button>
  </li>
)
```

最初のページはこのように書けます。基本的に`PaginationItem`は`onClick`に渡す値と、ボタンのテキストが変わるだけでほぼ同じ構成になります。

- FirstPaginationItem: 最初のページなので常に`1`を渡す
- LastPaginationItem: 最後のページは総ページ数と同じなので`total_pages`を渡す
- PrevPaginationItem: 1つ戻さないといけないので、`current_page - 1`する
- NextPaginationItem: 1つ進まないといけないので、`current_page + 1`する

のようになりますね。
ついでに `Gap` もやってしまいましょう。

```jsx
const Gap = () => (
  <li className="pagination-item">
    <span className="gap">&hellip;</span>
  </li>
)
```

`&hellip;` は `...` と表示されます。Gapは、ページ数が多い場合に、間を省略するために使用するコンポーネントです。

## Paginationを作成しよう

必要なコンポーネントができたので、`PaginationItem`をラップする`Pagination`コンポーネントを作成します。

```html
<ul className="pagination">
  {firstPage}
  {previousPage}
  {leftGap}
  {pages}
  {rightGap}
  {nextPage}
  {lastPage}
</ul>
```

完成時にはこのような形式で出力されて欲しいので、`{}`された部分を作っていきます。
とはいっても前項で`PaginationItem`を用意しているので、それ以外の `...[2][3][4][5][6]...` のような箇所を作ってみます。



```jsx
// 数の配列を作るために使用します
import { range } from 'lodash'
// 現在のページを起点に左右に表示するアイテムの数
const PADDING = 3

const Pagination = ({ pagination, onPaginationClick }) => {
  // 現在のページ
  const currentPage = pagination.current_page
  // 総ページ数
  const totalPages = pagination.total_pages

  ...省略

  const pages = [
    ...range(currentPage - PADDING, currentPage).filter(page => page >= 1),
    ...range(currentPage, currentPage + PADDING + 1).filter(page => page <= totalPages)
  ].map(page => {
    // 現在のページかどうかを判定
    const isCurrent = page === currentPage
    // 現在のページはspanで出力する
    const paginationItem = isCurrent
      ? <span>{page}</span>
      : <button type="button" onClick={() => onPaginationClick(page)}>{page}</button>

    return <li className="pagination-item" key={page}>{paginationItem}</li>
  })

  ...省略
}
```

最後に全体コードがあるので一部省略していますが、ポイントは以下ですね。
[lodashのrange](https://lodash.com/docs/4.17.5#range)メソッドを使って、出力したいページ番号の配列を作っています。

```js
const pages = [
  // 現在のページが4なら[1][2][3]
  ...range(currentPage - PADDING, currentPage).filter(page => page >= 1),
  // 現在のページが4で総ページ数が6なら[4][5][6]
  ...range(currentPage, currentPage + PADDING + 1).filter(page => page <= totalPages)
]
```

後はこのページ番号が格納された`pages`配列を使って、現在のページとそれ以外で出力するhtmlを出し分けるだけですね。


## 全体のコードを見てみよう

長い道のりを適度に端折ってきましたが、最後に全体のコードを載せて締めたいと思います。

```jsx
import PropTypes from 'prop-types'
import React from 'react'
import { range } from 'lodash'

const PADDING = 3

const FirstPaginationItem = ({ onClick }) => (
  <li className="pagination-item">
    <button type="button" onClick={() => onClick(1)}>
      &laquo;
    </button>
  </li>
)

const LastPaginationItem = ({ onClick, totalPages }) => (
  <li className="pagination-item">
    <button type="button" onClick={() => onClick(totalPages)}>
      &raquo;
    </button>
  </li>
)

const PrevPaginationItem = ({ onClick, prevPage }) => (
  <li className="pagination-item">
    <button type="button" onClick={() => onClick(prevPage)}>
      &lt;
    </button>
  </li>
)

const NextPaginationItem = ({ onClick, nextPage }) => (
  <li className="pagination-item">
    <button type="button" onClick={() => onClick(nextPage)}>
      &gt;
    </button>
  </li>
)

const Gap = () => (
  <li className="pagination-item">
    <span className="gap">&hellip;</span>
  </li>
)

const Pagination = ({ pagination, onPaginationClick }) => {
  const currentPage = pagination.current_page
  const totalPage = pagination.total_pages

  // 最初のページ
  const firstPage = currentPage > 1 ? <FirstPaginationItem onClick={onPaginationClick} /> : null

  // 最後のページ
  const lastPage =
    currentPage !== totalPages ? <LastPaginationItem onClick={onPaginationClick} totalPages={totalPages} /> : null

  // 1つ戻る
  const previousPage =
    currentPage >= 2 ? <PrevPaginationItem onClick={onPaginationClick} prevPage={currentPage - 1} /> : null

  // 1つ進む
  const nextPage =
    currentPage + 1 <= totalPages ? <NextPaginationItem onClick={onPaginationClick} nextPage={currentPage + 1} /> : null

  // 現在のページと現在のページの左右にPADDINGで指定した数のアイテムを作成します
  const pages = [
    ...range(currentPage - PADDING, currentPage).filter(page => page >= 1),
    ...range(currentPage, currentPage + PADDING + 1).filter(page => page <= totalPages)
  ].map(page => {
    // 現在のページかどうかを判定
    const isCurrent = page === currentPage
    // 現在のページはspanで出力する
    const paginationItem = isCurrent ? (
      <span>{page}</span>
    ) : (
      <button type="button" onClick={() => onPaginationClick(page)}>
        {page}
      </button>
    )

    return (
      <li className="pagination-item" key={page}>
        {paginationItem}
      </li>
    )
  })

  // 左右に `...` を表示する条件を追加
  const leftGap = currentPage > PADDING + 1 ? <Gap /> : null
  const rightGap = currentPage + PADDING < totalPages ? <Gap /> : null

  return (
    <ul className="pagination">
      {firstPage}
      {previousPage}
      {leftGap}
      {pages}
      {rightGap}
      {nextPage}
      {lastPage}
    </ul>
  )
}

FirstPaginationItem.propTypes = {
  onClick: PropTypes.func.isRequired
}

LastPaginationItem.propTypes = {
  onClick: PropTypes.func.isRequired,
  totalPages: PropTypes.number.isRequired
}

PrevPaginationItem.propTypes = {
  onClick: PropTypes.func.isRequired,
  prevPage: PropTypes.number.isRequired
}

NextPaginationItem.propTypes = {
  onClick: PropTypes.func.isRequired,
  nextPage: PropTypes.number.isRequired
}

Pagination.propTypes = {
  onPaginationClick: PropTypes.func.isRequired,
  pagination: PropTypes.object.isRequired
}

export default Pagination
```

# まとめ

kaminariのviewをjsに移植したコードをあれこれ探して見ていたのですが、いまいちピンとくるものがなく、「それなら自分で書いてみよう。」と思ったのが今回の発端です。
で、実装完了した時に**「これやりたい人結構多いと思いますよ :bulb:」**と、同じ開発チームの優秀エンジニアが言ってくれたので書いた次第です。

実際、このコンポーネントがあれば必要な箇所でpropsだけ渡してあげれば使い所も多いのではないかと思っています。