vte.cxとReact Hooksでページネーションの実装

#概要

**[前回作成したアプリ](https://qiita.com/kako-2748/items/55a67a24ac29ef657045)**に**[vte.cx](https://blog.vte.cx/)**でページネーションの機能を実装しました。
**[vte.cx](https://blog.vte.cx/)**について詳しく知りたい方は**[vte.cxのドキュメント](https://vte.cx/documentation.html)**をご覧ください。

今回実装したページネーションの仕様は以下になります。
 
- **初期表示は1ページ目**
- **総件数を取得する**
- **「次へ」ボタンを押すと、+1ページ目に移動する**
- **「前へ」ボタンを押すと、-1ページ目に移動する**
- **「最初」ボタンを押すと、1ページ目に移動する**
- **「最後」ボタンを押すと、最後のページに移動する**
- **現在のページを表示する**
- **ページの最終番号以降のリクエストが来た時にindexを追加で貼り直す**

#実装
今回ページネーションを実装したファイルは、前回作った一覧画面(ListProf)と、今回新しく作成したページネーション(Pagination)ファイルになります。

##一覧画面(ListProf)。

```tsx:ListProf.tsx

import * as React from 'react'
import { useState, useRef } from 'react'
import axios from 'axios'
import { withRouter } from 'react-router-dom'

import Pagination from './Pagination'
import SearchList from './SearchList'

//一覧画面
const ListProf = (props:any) => {
  const [deletedPage, setDeletedPage] = useState(1)
  const [feed, setFeed] = useState<VtecxApp.Entry[]>([])
  const [firstIndexPage, setFirstIndexPage] = useState(1)
  const [lastIndexPage, setLastIndexPage] = useState(50)
  
  const feedLength = useRef(0)
  const lastPage = useRef(0)
  //データの総件数取得関数
  const getFeedLength = async() => {
    try {
        axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
        const res = await axios.get('/d/foo?f&c&l=*')
        feedLength.current = (Number(res.data.feed.title))
        lastPage.current = (Math.ceil(res.data.feed.title / 5))
         
    } catch {
      alert('総件数の取得に失敗しました。')

    }
 }
  //pageindex作成リクエスト関数
  const putIndex = async(currentPage:number) => {
    try {
      let firstIndex = firstIndexPage
      let lastIndex = lastIndexPage

      if(currentPage > firstIndexPage && currentPage > lastIndexPage){
        firstIndex = firstIndex + 50
        lastIndex = lastIndex + 50 
      }

      axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
      await axios.get('/d/foo?f&_pagination='+firstIndex+','+lastIndex+'&l=1')
      setFirstIndexPage(firstIndex)
      setLastIndexPage(lastIndex)
     
    } catch {
      alert('indexが貼れませんでした。')
    }
    
  }
  //指定ページの一覧取得関数
  const getPage = async(currentPage:number, retry_count:number)  => {
    try {
      axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
      const res = await axios.get('/d/foo?f&n='+ currentPage + '&l=5')
      setFeed(res.data)
      
    } catch(e) {
      const retry = () => {
        retry_count++

        const retryIndex = () => { 
          getPage(currentPage, retry_count)
      
        }

        if(retry_count > 9) {
          alert('ページが取得できませんでした')
          return false 
        }else{
          setTimeout(() => retryIndex(),1000) 
        }
      }
      
      //indexが貼れていなかった場合10回までindexリクエストをリトライする
      if(e.response.data.feed.title === "Please make a pagination index in advance.") {
       retry()
      //ページの最終番号以降のリクエストが来た時にindexを追加で貼り直す
      } else if(e.response.data.feed.title === "Please set a positive number for Page number.") {
        putIndex(currentPage)
        retry()
      }

    } 
    
  }

  //削除関数
  const deleteEntry = async (entry:VtecxApp.Entry, index:number) => {
    try {
      axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
      
      if(entry && entry.link) {
         const key = entry.link[0].___href
         await axios.delete('/d'+ key)
         getFeedLength()
         putIndex(deletedPage)

         if(index === 0 && deletedPage !== 1){
           getPage(deletedPage -1, 0)
         } else {
           getPage(deletedPage, 0)
         }
         
      }
      alert('削除しました')

    } catch (e) {
      alert('削除できませんでした')

    }
  }

       return ( 
        <div>
        { feed.length > 0 ? 
        <div>
          <SearchList setFeed={setFeed} 
          feedLength={feedLength.current}
          lastPage={lastPage.current} />
          <table>
              <tr>
                  <th>名前</th>
                  <th>メール</th>
                  <th>職業</th>
                  <th>住所</th>
                  <th>身長</th>
                  <th>誕生日</th>
                  <th>性別</th>
                  <th>チェック</th>
                  <th>セレクト</th>
                  <th>メモ</th>
              </tr>

                {feed.map((entry, index) => (
              <tr>   
                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.name}, data: entry, title: 'name', type: 'text'})}>
                     {entry.user!.name}</a></td>
                
                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                    state: {text:entry.user!.email},data: entry, title: 'email', type: 'text'})}>
                    {entry.user!.email}</a></td>

                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.job},data: entry, title:'job', type: 'text'})}>
                     {entry.user!.job}</a></td>
                
                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.address},data: entry, title: 'address', type: 'text'})}>
                     {entry.user!.address}</a></td>

                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.height},data: entry, title: 'height', type: 'text'})}>
                     {entry.user!.height}</a></td>

                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.birthday},data: entry, title: 'birthday'})}>
                     {entry.user!.birthday}</a></td>

                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.gender},data: entry, title: 'gender'})}>
                     {entry.user!.gender}</a></td>

                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.check},data: entry, title: 'check'})}>
                     {entry.user!.check}</a></td>
                
                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.select},data: entry, title: 'select'})}>
                     {entry.user!.select}</a></td>

                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.memo},data: entry, title: 'memo'})}>
                     {entry.user!.memo}</a></td>
                     <td><button onClick={() => deleteEntry(entry, index)}>削除</button></td>  
              </tr>
              ))} 
            </table>
          </div>
        :
         <p>登録されていません</p>
        }
        <Pagination
         setDeletedPage={setDeletedPage} getFeedLength={getFeedLength}
         getPage={(e:number) => getPage(e, 0)} putIndex={(e:number) => putIndex(e)}
         feedLength={feedLength.current} lastPage={lastPage.current}
         lastIndexPage={lastIndexPage}
       />
        <button onClick={() => props.history.push('/RegisterProf')}>新規登録</button>
       </div> 
    )
  }
  export default withRouter(ListProf)
```
前回あったgetFeed関数がなくなって**getFeedLength, PutIndex, getPage関数**が増えています。
ページネーションを実装するには、以下の3つのリクエストを実行する必要があり,３つのリクエストを**getFeedLength, PutIndex, getPage関数**で実行しています。

- **一覧の総件数取得リクエスト(getFeedLength)**
- **カーソル(pageindex)作成リクエスト(putIndex)**
- **指定ページの一覧取得リクエスト(getPage)**


まずは、**一覧の総件数取得リクエスト(getFeedLength)**から見ていきましょう。

###一覧の総件数取得リクエスト(getFeedLength)
```tsx
  const feedLength = useRef(0)
  const lastPage = useRef(0)
  //データの総件数取得関数
  const getFeedLength = async() => {
    try {
        axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
        const res = await axios.get('/d/foo?f&c&l=*')
        feedLength.current = (Number(res.data.feed.title))
        lastPage.current = (Math.ceil(res.data.feed.title / 5))

    } catch {
      alert('総件数の取得に失敗しました。')

    }
 }
```
この関数で実行される大まかな流れは、以下のようになります。

1. 変数`res`で総件数取得リクエストを実行
2. `res`で受け取った総件数を``feedLength``に格納
3. `res`で受け取った総件数を1ページあたりに表示する件数(5件)で割り、最終ページを計算して`lastPage`に格納
4. エラーが出た場合はアラートを出す

tryで最初に実行される、HTTPヘッダに`X-Requested-With: XMLHttpRequest`をつけているのはセキュリティ(CSRF対策)のためですが、ここではおまじないだと思って必ずつけるということだけ覚えてください。

```tsx
 axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
 const res = await axios.get('/d/foo?f&c&l=*')
```
総件数は``c&l=*``で取得しています。
**c**で配下のエントリ件数を取得し、**l**で取得可能なエントリの最大件数を指定しています。``l=*``とすると取得総件数を無制限にできます。
つまり、変数``res``で``/d/foo``配下のエントリの総件数(無制限)を取得する。ということになります。

f：配下のエントリ一覧を取得
l：取得可能なエントリの最大件数(*ですべて）
c：配下のエントリ件数を取得

実際に取得したエントリを``console.log(res)``で出力してみると、下の画像のようになると思います。
![スクリーンショット 2019-10-10 10.11.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478072/42fb6cfa-1b1f-d9f3-dc4f-a7afaf28a7cd.png)
``res.data.feed.title``の中に入っている``"64"``が総件数になります。
よく見ると、64がダブルクォーテーションで囲まれているのが分かると思います。
これを``Number``で数字に変換して、``feedLength``に総件数として格納しています。
``lastPage``も同じ感じで格納するのですが、計算した値が割り切れなかった際に、繰り上げをして最終ページを算出したいので、``Math.ceil``を使い総件数(今回は64件)を1ページあたりに表示したい件数(5件)で割って、``lastPage``に最終ページとして格納しています。

```tsx
feedLength.current = (Number(res.data.feed.title))
lastPage.current = (Math.ceil(res.data.feed.title / 5))
```

<br />
次に**カーソル(pageindex)作成リクエスト(putIndex)**を見ていきましょう。
###カーソル(pageindex)作成リクエスト(putIndex)

```tsx
 const putIndex = async(currentPage:number) => {
    try {
      let firstIndex = firstIndexPage
      let lastIndex = lastIndexPage

      if(currentPage > firstIndexPage && currentPage > lastIndexPage){
        firstIndex = firstIndex + 50
        lastIndex = lastIndex + 50 
      }

      axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
      await axios.get('/d/foo?f&_pagination='+firstIndex+','+lastIndex+'&l=5')
      setFirstIndexPage(firstIndex)
      setLastIndexPage(lastIndex)

    } catch {
      alert('indexが貼れませんでした。')
    }

  }
```
この関数で実行される大まかな流れは、以下のようになります。

1. 変数``firstIndex``と``lastIndex``を定義し、カーソル作成リクエストを送る際の開始と最終ページ数を指定する
2. 現在のページ数(currentPage)が現在貼ってあるpageindexよりも値が大きかった場合<br />``firstIndex``　と
``lastIndex``をプラス50する
3. カーソル作成リクエストを実行する
4. 「3.」を実行した際の最初と最後のページ数を記憶しておくために、useStateを使い<br />``firstIndexPage``に``firstIndex``、``lastIndexPage``に``lastIndex``を格納する
5. エラーが出た場合はアラートを出す

まずカーソル作成リクエストを実行しているところから説明します。

```tsx
await axios.get('/d/foo?f&_pagination='+firstIndex+','+lastIndex+'&l=5')
```

カーソル作成リクエストを実行するには、上記のように
``/d/foo?f``の後に``_pagination={開始ページ},{最終ページ}&l={件数}``を挿入します。
大量のカーソル一覧(pageindex)を張るのは時間がかかるため、最初のリクエストは最終ページ数を50にとどめておきます。

```tsx
 let firstIndex = firstIndexPage
      let lastIndex = lastIndexPage

      if(currentPage > firstIndexPage && currentPage > lastIndexPage){
        firstIndex = firstIndex + 50
        lastIndex = lastIndex + 50 
      }
```
開始ページを``firstIndex``、最終ページを``lastIndex``にしているのは、このputIndex関数を実行した際に現在のページ数(currentPage)が最終ページ(lastIndex)を超えていた場合に上記のように追加で50ページ分のカーソルを作成するために値を記憶しておく必要があるからです。

```tsx
  setFirstIndexPage(firstIndex)
  setLastIndexPage(lastIndex)
```
そのため、カーソル作成リクエストを実行した後に、``setFirstIndexPage(firstIndex)``と<br />``setLastIndexPage(lastIndex)``を実行して開始ページと最終ページを記憶しておきます。
<br />

最後に**指定ページの一覧取得リクエスト(getPage)**を見ていきましょう。
###指定ページの一覧取得リクエスト(getPage)

```tsx
  const getPage = async(currentPage:number, retry_count:number)  => {
    try {
      axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
      const res = await axios.get('/d/foo?f&n='+ currentPage + '&l=5')
      setFeed(res.data)

    } catch(e) {
      const retry = () => {
        retry_count++

        const retryIndex = () => { 
          getPage(currentPage, retry_count)

        }

        if(retry_count > 9) {
          alert('ページが取得できませんでした')
          return false 
        }else{
          setTimeout(() => retryIndex(),1000) 
        }
      }

      //indexが貼れていなかった場合getPageをリトライする
      if(e.response.data.feed.title === "Please make a pagination index in advance.") {
       retry()
      //ページの最終番号以降のリクエストが来た時にpageindexを追加で貼り直す
      } else if(e.response.data.feed.title === "Please set a positive number for Page number.") {
        putIndex(currentPage)
        retry()
      }

    } 

  }
```
この関数で実行される大まかな流れは、以下のようになります。

1. 指定ページの一覧取得リクエストを実行する
2. 取得したデータを``setFeed``を使い``feed``に格納する
3. エラーが出た場合は10回までリトライする

まずは指定ページの一覧取得リクエストを実行するところから説明していきます。

```tsx
 const res = await axios.get('/d/foo?f&n='+ currentPage + '&l=5')
 setFeed(res.data)
```
指定ページの一覧取得リクエストを実行するには、
上記のように``/d/foo?f``の後に``n={表示したいページ数}&l={表示したい件数}``を挿入します。
表示したいページ数は、ページ数を切り替えるボタンを押下した際にページ数を切り替えることができるように``currentPage``を指定します。表示したい件数は今回は5件とします。

``console.log(res)``で出力してみると下の画像のようになると思います。
![スクリーンショット 2019-10-10 14.03.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478072/4a46a50a-d02b-6c98-6850-d8b9db278606.png)
表示したい一覧は、``res.data``の中に配列で入っています。
この配列を画面に表示させるために、``setFeed``で``feed``の中に格納します。
<br />
次にエラーが出た際にリトライする処理について説明します。
指定ページの一覧を取得するには、あらかじめカーソル一覧(pageindex)を作成しておく必要があります。検索時、指定したページ数のカーソル一覧がセッションに存在しない場合は400エラーとなり、下の画像のように「Please make a pagination index in advance. 」のメッセージが返ります。また、検索した結果、最終ページ数に満たない場合は「Please set a positive number for Page number.」のメッセージが返ります。
![スクリーンショット 2019-10-10 14.26.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478072/91a892a4-b305-a118-70ce-6364d8092c2c.png)

 特に「Please make a pagination index in advance. 」のエラーは度々起こります。``putIndex``リクエストでカーソル作成リクエストを行っていますが、これは非同期で行っているため、カーソル(pageindex)が作成完了する前にレスポンスが返却されています。よって、カーソルが作成される前にページ取得リクエストを実行すると上記エラーが発生します。 これを解決するためには以下のようにif文を使いリトライ関数を実行します。
「Please set a positive number for Page number.」とエラーが出ている場合は、putIndexも一緒に実行することで、追加でカーソルを作成します。

```tsx
 //indexが貼れていなかった場合10回までindexリクエストをリトライする
 if(e.response.data.feed.title === "Please make a pagination index in advance.") {
     retry()
 //ページの最終番号以降のリクエストが来た時にindexを追加で貼り直す
 } else if(e.response.data.feed.title === "Please set a positive number for Page number.") {
     putIndex(currentPage)
     retry()
 }
```
``retry``関数が実行されると、``getPage``の引数に指定してある``retry_count``をプラス1して``setTimeout``で1秒後に``retryIndex``を実行し,リトライします。``retry_count``でリトライした回数を数えて10回実行してもエラーが解決しなかったらリトライを止めてアラートを出します。

```tsx
const retry = () => {
        retry_count++

        const retryIndex = () => { 
          getPage(currentPage, retry_count)

        }

        if(retry_count > 9) {
          alert('ページが取得できませんでした')
          return false 
        }else{
          setTimeout(() => retryIndex(),1000) 
        }
      }
```
以上の3つの関数を実行することで、ページネーションの機能が実装されます。
実際に関数を実行する処理はページネーションファイルの方になります。
では、ページネーションファイルの方を見ていきましょう。


###ページネーションファイル
```tsx:Pagination.tsx
 import * as React from 'react' 

 import { useState, useEffect, useMemo} from 'react' 
  
 //ページネーションコンポーネント 
 const Pagination = (_props:any) => { 
     const [currentPage, setCurrentPage] = useState(1) 
  
     const memoPutIndex = useMemo(() => { 
       _props.putIndex(currentPage)  
     },[currentPage > _props.lastIndexPage]) 
    
     const memoGetFeedLength = useMemo(() => { 
         _props.getFeedLength() 
     },[]) 
  
     //画面表示処理 
     useEffect(() => { 
          memoGetFeedLength 
          memoPutIndex 
          _props.getPage(currentPage, 0) 
          _props.setDeletedPage(currentPage) 
          
     },[currentPage]) 
  
     return ( 
       <div> 
         <b>総件数:{_props.feedLength}</b><br /> 
             <b>現在のページ:{currentPage}</b> 
             <ul> 
               <li><a href="javascript:void(0)" 
              onClick={() => currentPage === 1 ? null: setCurrentPage(1) }>最初</a></li> 
               <li><a href="javascript:void(0)" 
               onClick={() => currentPage === 1 ? null: setCurrentPage(currentPage - 1)}>前へ</a></li> 
               <li><a href="javascript:void(0)"  
               onClick={() => currentPage === _props.lastPage ? null: setCurrentPage(currentPage + 1)} >次へ</a></li> 
               <li><a href="javascript:void(0)" 
               onClick={() => currentPage === _props.lastPage ? null: setCurrentPage(_props.lastPage)}>最後</a></li> 
             </ul> 
      </div>   
     ) 
    
   } 
  
 export default Pagination 
```

一覧画面(ListProf)で定義したページネーションを実装するための関数を``useEffect``で実行しています。
ページ数が変わるたびに関数を実行するために、第2引数に``currentPage``を指定します。

```tsx
 const memoPutIndex = useMemo(() => { 
       _props.putIndex(currentPage)  
     },[currentPage > _props.lastIndexPage]) 

     const memoGetFeedLength = useMemo(() => { 
         _props.getFeedLength() 
     },[]) 

 //画面表示処理 
 useEffect(() => { 
      memoGetFeedLength 
      memoPutIndex 
     _props.getPage(currentPage, 0) 
     _props.setDeletedPage(currentPage) 

 },[currentPage]) 

```
総件数の取得(getFeedLength)とカーソル作成(putIndex)はページ数が変わるたびに実行する必要はないため、``useMemo``でメモ化しておきます。

ただし、``putIndex``は現在のページ数が最終ページを超えていた場合だけ再実行できるように第2引数に``currentPage > _props.lastIndexPage``を指定します。
<br />

```tsx
return ( 
       <div> 
         <b>総件数:{_props.feedLength}</b><br /> 
             <b>現在のページ:{currentPage}</b> 
             <ul> 
               <li><a href="javascript:void(0)" 
              onClick={() => currentPage === 1 ? null: setCurrentPage(1) }>最初</a></li> 
               <li><a href="javascript:void(0)" 
               onClick={() => currentPage === 1 ? null: setCurrentPage(currentPage - 1)}>前へ</a></li> 
               <li><a href="javascript:void(0)"  
               onClick={() => currentPage === _props.lastPage ? null: setCurrentPage(currentPage + 1)} >次へ</a></li> 
               <li><a href="javascript:void(0)" 
               onClick={() => currentPage === _props.lastPage ? null: setCurrentPage(_props.lastPage)}>最後</a></li> 
             </ul> 
      </div>   
     ) 
```

あとはページを切り替えるためのボタンを作るだけです。
無限にページ数が増えたり減ったりしないようにif文で判定してnullを返すことでページ数が切り替わらないようにします。
#終わりに
今回はページネーションを実装する際の３つのリクエストがどのリクエストがなにをやっているのか理解できずに苦労しました。
また、useRefやuseMemoなどの今まで使っていなかったHooksを使ってみたりして、React HooksとVte.cxの理解が深まったと思います。

<br />

**以上。**