はじめに
前回作ったReact + TypeScript + vte.cxで簡単なWebアプリを作ってみた①基本的なCRUDアプリ
vte.cxのページネーション機能を使って今回はReactでページネーションを機能を実装しました。
今回作ったアプリ
前回作ったアプリを基にページネーション機能の実装を行いました。
ページネーション is 何
こんな感じのですがほとんどの人がネット上でみたことがあると思います。
実装する以前はただ情報を見つける時の労力を減らすためだけのものだと思っていたのですが今回実装してみてそれ以外の良い効果もあるのだと初めて気づくことができました。
前回のアプリを見てみましょう。
登録した情報をページに分けることなくそのままずらっと表示させています。
でもこれってデータが少ない時は問題ないですけどアプリによっては登録件数1000件,2000件下手したら1万件とか全然ザラだと思います。
それをそのまま全件表示してしまうと読み込みにもめちゃめちゃ時間がかかりますし、1万件のデータの中から7434件目のデータを見るといった時にページ分けしてないと見つけるのにめちゃめちゃ時間が掛かっちゃったりして精神的にもデータの負荷的にも時間的にもかなり地獄なわけです。
と言うことでいろいろな負荷を減らすためのページネーションを実装しました。
余談ですが
ページ = pageなので
ページネーション = pag「e」nation
だと思って途中までずっとそう書いていたのですが正しくは
pag「i」nationらしいです笑
Pagination.tsx(ページの切り替えを担当するコンポーネント)
今回は前回作成したUserInfo.tsxコンポーネントをいじるのに加えて、Pagination.tsxと言うコンポーネントを作りました。
まずページネーションのロジックを管理しているPagination.tsxコンポーネントの全体図です
import * as React from 'react'
import { useState, useEffect, useRef } from 'react'
interface Props {
sum: number
per: number
onChange: (e: { page: number }) => void
}
const Pagination = (props: Props) => {
const [currentPage, setPage] = useState(1)
const isFirstRender = useRef(true)
// 初期にはuseEffectでprops.onChangeを実行しないようにしている
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
return
}
// currentPageに変化があったときに親コンポーネントにcurrentpage番号を渡す
props.onChange({ page: currentPage });
}, [currentPage])
const totalPage: number = Math.ceil(props.sum / props.per)
// 切り上げをしていてtotalPageが4.3などだった場合5にしている
const totalPageArray: number[] = Array.from(new Array(totalPage)).map((_, i) => i++);
// 「前へ」ボタンを押したときの処理
const handleBack = (): void => {
//1ページ目の場合の処理
if (currentPage === 1) {
return
}
setPage(currentPage - 1)
}
// 「次へ」ボタンを押したときの処理
const handleForward = (): void => {
//最後のページで押したときの処理
if (currentPage === totalPage) {
return
}
setPage(currentPage + 1)
}
// 「最初」ボタンを押したときの処理
const handleToFirstPage = (): void => {
setPage(1)
}
// 「最後」ボタンを押したときの処理
const handleToLastPage = (): void => {
setPage(totalPage)
}
//ページボタンを押したときの処理
const handleMove = (page: number): void => {
setPage(page)
}
return (
<>
<button onClick={handleToFirstPage}>最初へ</button>
<button onClick={handleBack}>«前へ</button>
{totalPageArray.map(page => {
// 配列に格納されている番号は0スタートなので最初に+1している
page++
return page === currentPage ? (
<span style={{ cursor: 'pointer', margin: '0 5px' }} key={page} onClick={() => handleMove(page)}>
{page}
</span>
) : (
<span style={{ cursor: 'pointer', margin: '0 5px' }} key={page} onClick={() => handleMove(page)}>{page}</span>
)
})}
<button onClick={handleForward}>次へ»</button>
<button onClick={handleToLastPage}>最後へ</button>
</>
)
}
export default Pagination
ページネーションでは上記のサイトを参考にしました。
React + Hooksで作るページネーションコンポーネント
ページネーションコンポーネントで必要な情報は、
ページ件数と1ページあたりどのくらいの件数を載せるか
この2点だけです。
今回は1ページあたり5件、総件数20件数未満で取得していますがもしこの総件数が300件でも1万件でもちゃんと動作されるようになっています。
例えば総件数30件、1ページには8件のせたいとします。
こうするとページ数は何ページになると思いますか?
30(総件数) ÷ 8(1ページあたりの件数)
と最初は計算しましたがこれだと答えが3.75となってしまいます。
小数点が出てしまうと、ページ数がおかしいことになってしまいます。
そこで今回は
ceilメソッドを使って切り上げています。
const totalPage: number = Math.ceil(props.sum / props.per)
こうすることで
30(総件数) ÷ 8(1ページあたりの件数)
とした時、3.75を切り上げて4ページとしてくれます。
これで正しいページ表示ができます。
データを登録や消去した時に総件数が変わります。
これでページ数が導けました。
でもこれだけではダメで、ページネーションは
1,2,3,4,5
このように連番に表示される必要があります。
どうするかと言うとArray.form()
を使います。
// 連番の生成
// 配列はそれぞれの場所が `undefined` で初期化されるため、
// 以下の `v` のの値は `undefined` になる
Array.from({length: 5}, (v, i) => i);
// [0, 1, 2, 3, 4]
公式を参考にしました。
このコンポーネントでは
const totalPageArray: number[] = Array.from(new Array(totalPage))
.map((_, i) => i++);
とすることで
const totalPageArray = [1,2,3.....n]
上記のような連番の数字が入った配列を作ることができています。
これを
return (
<>
{totalPageArray.map(page => {
// 配列に格納されている番号は0スタートなので最初に+1している
page++
return page === currentPage ? (
<span style={{ cursor: 'pointer', margin: '0 5px' }} key={page} onClick={() => handleMove(page)}>
{page}
</span>
) : (
<span style={{ cursor: 'pointer', margin: '0 5px' }} key={page} onClick={() => handleMove(page)}>{page}</span>
)
})}
)
として
map関数を使い、totalPageArrayに入っている数字の数だけ
<span onClick={() => handleMove(page)}>1</span>
<span onClick={() => handleMove(page)>2</span>
<span onClick={() => handleMove(page)>3</span>
といったタグを作っています。このタグにはonClickイベントが入っており、押下するとそのタグが持っている数字(ページ)を引数にしたページネーション操作をすることができます。
UserInfo.tsx(情報の一覧表示をするコンポーネント)
import * as React from 'react'
import { useState, useEffect, useContext, useRef } from 'react'
import axios from 'axios'
import UserList from './UserList'
import { Store } from './App'
import Pagination from './Pagination'
const UserInfo = () => {
const [users, setUsers] = useState([])
const [sumPageNumber, setSumPageNumber] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const { dispatch } = useContext(Store)
// 1ページに表示させる件数
const displayPage = 5
// コンポーネントマウント後に以下のページインデックスを作成する関数が実行される
// 初期描画の実行
useEffect(() => {
getTotalFeedNumber()
}, [])
// 初期描画後
// 最初にページインデックスを作成終了後、handlePaginateで1ページを指定している
const mounted = useRef(false)
useEffect(() => {
if (mounted.current) {
handlePaginate(1)
console.log('useEffect作動sumPageNumber')
} else {
mounted.current = true
}
}, [sumPageNumber])
//ページの取得処理
let retryCount = 0
// この処理をgetTotalFeedNumberを処理したときに実行したい
//page番号を使ってAPIを叩く処理
const handlePaginate = async (page: number) => {
const LIMIT_RETRY_COUNT = 10
try {
dispatch({ type: 'SHOW_INDICATOR' })
axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
await axios.get(`/d/users?n=${page}&l=${displayPage}`).then((res) => {
if (res && res.data && res.data.length) {
setUsers(res.data)
}
setCurrentPage(page)
}).then(() => {
retryCount = 0
dispatch({ type: 'HIDE_INDICATOR' })
})
} catch (e) {
if (e.response.data.feed.title === 'This process is still in progress. Please wait.') {
retryCount++
console.log(retryCount)
if (retryCount < LIMIT_RETRY_COUNT) {
handlePaginate(page)
} else {
dispatch({ type: 'HIDE_INDICATOR' })
alert('error:' + e)
alert('Process error')
}
}
if (e.response.data.feed.title === 'Please make a pagination index in advance.') {
retryCount++
console.log(retryCount)
if (retryCount < LIMIT_RETRY_COUNT) {
handlePaginate(page)
} else {
dispatch({ type: 'HIDE_INDICATOR' })
alert('error:' + e)
alert('Not create pagination index')
}
}
}
}
// paginationIndexを作成する処理
const getTotalFeedNumber = async () => {
try {
dispatch({ type: 'SHOW_INDICATOR' })
axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
await axios.get(`/d/users?_pagination=1,4&l=${displayPage}`).then((res) => {
setSumPageNumber(res.data.feed.subtitle)
}).then(() => {
dispatch({ type: 'HIDE_INDICATOR' })
})
} catch (e) {
alert('error:' + e)
}
}
return (
<>
<p>総件数:<span style={{ color: 'blue' }}>{sumPageNumber}</span>件</p>
<p>現在<span style={{ color: 'blue' }}>{currentPage}</span>ページ目</p>
<UserList info={users} getFormData={getTotalFeedNumber} />
<Pagination sum={sumPageNumber} per={displayPage} onChange={e => handlePaginate(e.page)} />
</>
)
}
export default UserInfo
今回はページを分けての表示になります。
コンポーネントのレンダリング時に少し細工が必要になります。
Ajax通信をする際にページを分けて情報を取得する必要があります。
この情報の取得方法は公式ドキュメントに記載されています。
ページを分けて取得するには2回の通信が必要になります。
####1回目の通信
await axios.get(`/d/users?_pagination=1,4&l=5`)
ここではインデックスを貼ると言う作業をします。
このインデックスを貼るという表現がAPIを叩くという表現と同じくらい初心者からするとイメージできない言葉になっているので調べてみました。
#INDEXを貼る is 何
まずINDEXという言葉からです。
作るとデータ参照が速くなるやつ
大量のレコードが入っているテーブルから1行のレコードを検索するのに
頭から順番に検索したら時間がかかります。
INDEXを作成すると、データテーブルとは別に検索用に最適化された状態でデータが保存されます。
このINDEXを使うことで、目的のレコードを迅速に見つけて取り出すことが可能になります。
引用元:MySQLでインデックスを貼る時に読みたいページまとめ(初心者向け)
このINDEXを作ることをINDEXを貼ると言います。
例えばめちゃめちゃ分厚い辞書の中から必要な情報にアクセスするにはかなり時間がかかるし疲れます。
でももしそれが自分に必要そうな情報だけがのっているポケットサイズの辞書だとするとかなり見つかりやすくなります。
右がインデックスを貼らずのそのままのデータ
左がインデックス
ちなみにこのインデックスは1個だけでなく、何個も作ることができます。
2回目の通信
await axios.get(`/d/users?n=1&l=5`)
nにはインデックスのうち欲しいページの数字を
lにはページの件数をそれぞれ記入します。
※1回目と2回目のlの数字が違うとエラーを起こしてしまうので必ず統一しましょう。
UserInfo.tsxでは
1回目のAjax通信
await axios.get(`/d/users?_pagination=1,4&l=${displayPage}`).then((res) => {
setSumPageNumber(res.data.feed.subtitle)
})
await axios.get(`/d/users?_pagination=1,4&l=${displayPage}`)
によりそのインデックスの件数を取得することができます。今回thenを使ってresという引数で渡したあとにsumPageNumberというstateに格納しています。
2回目のAjax通信
await axios.get(`/d/users?n=${page}&l=${displayPage}`).then((res) => {
if (res && res.data && res.data.length) {
setUsers(res.data)
}
setCurrentPage(page)
})
await axios.get(`/d/users?n=${page}&l=${displayPage}`)
により、今度はそのインデックスにあるページのデータを取得することができています。
先ほどのようにthenを使ってresという引数で渡したあとにusersというstateに格納しています。
として1回目と2回目の1ページあたりの件数を
const displayPage = 5
とすることで共通の数字に必ずなるようにしています。
ではインデックス周りの説明ができたので、ページネーションコンポーネントとどのように組み合わせて作動させているのか説明していきます。
UserInfoコンポーネントでは
const [users, setUsers] = useState([])
const [sumPageNumber, setSumPageNumber] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
として3つのstateを管理しています。
1つめは前回でも使ったusersのデータを格納しているstate
2つめは総件数
3つめは現在何ページにいるのかを管理しています。
まずuseEffectで
useEffect(() => {
getTotalFeedNumber()
}, [])
getTotalFeedNumber()というメソッドを初回レンダリング時のみ作動させています。
const getTotalFeedNumber = async () => {
try {
dispatch({ type: 'SHOW_INDICATOR' })
axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
await axios.get(`/d/users?_pagination=1,4&l=${displayPage}`).then((res) => {
setSumPageNumber(res.data.feed.subtitle)
}).then(() => {
dispatch({ type: 'HIDE_INDICATOR' })
})
} catch (e) {
alert('error:' + e)
}
}
これは先ほど説明したインデックスを貼るメソッドあり、総件数を取得しています。
次に
const mounted = useRef(false)
useEffect(() => {
if (mounted.current) {
handlePaginate(1)
console.log('useEffect作動sumPageNumber')
} else {
mounted.current = true
}
}, [sumPageNumber])
useEffectでsumPageNumberに変化があった時にhandlePaginateメソッドが作動するようになっています。
こちらのuseEffectは初回レンダリング時に作動しないようにuseRefというhookを使って制御しています。
この制御をしないと初期レンダリングの際にgetTotalFeedNumberが作動し、sumPageNumberの数値が変わり、最初のページ表示時の挙動がおかしくなってしまいます。
ではsumPageNumberに変化があった時に作動するhandlePaginateメソッドを見てみましょう。
const handlePaginate = async (page: number) => {
const LIMIT_RETRY_COUNT = 10
try {
dispatch({ type: 'SHOW_INDICATOR' })
axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
await axios.get(`/d/users?n=${page}&l=${displayPage}`).then((res) => {
if (res && res.data && res.data.length) {
setUsers(res.data)
}
setCurrentPage(page)
}).then(() => {
retryCount = 0
dispatch({ type: 'HIDE_INDICATOR' })
})
} catch (e) {
if (e.response.data.feed.title === 'This process is still in progress. Please wait.') {
retryCount++
console.log(retryCount)
if (retryCount < LIMIT_RETRY_COUNT) {
handlePaginate(page)
} else {
dispatch({ type: 'HIDE_INDICATOR' })
alert('error:' + e)
alert('Process error')
}
}
if (e.response.data.feed.title === 'Please make a pagination index in advance.') {
retryCount++
console.log(retryCount)
if (retryCount < LIMIT_RETRY_COUNT) {
handlePaginate(page)
} else {
dispatch({ type: 'HIDE_INDICATOR' })
alert('error:' + e)
alert('Not create pagination index')
}
}
}
}
このメソッドはページネーションをするものなのですが、もしページネーションをするときにインデックスが貼られていなかった場合、最大10回のAjax通信のリトライをするようになっています。
handlePaginateの引数は
await axios.get(`/d/users?n=${page}&l=${displayPage}`)
この${page}
部分に入ります。
こうすることで欲しいページの情報をusersに格納することができます。
return (
<>
<Pagination sum={sumPageNumber} per={displayPage} onChange={e => handlePaginate(e.page)} />
</>
)
PaginationコンポーネントにはpropsでhandlePaginate(e.page)を渡しており、Paginationコンポーネントの持っているstateであるcurrentPageの値が変わるごとにuseEffectでprops.onChange({page:currentPage})
として引数である現在のページ番号を親に渡しています。
const Pagination = (props: Props) => {
const [currentPage, setPage] = useState(1)
const isFirstRender = useRef(true)
// 初期にはuseEffectでprops.onChangeを実行しないようにしている
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false
return
}
// currentPageに変化があったときに親コンポーネントにcurrentpage番号を渡す
props.onChange({ page: currentPage });
}, [currentPage])
このようにすることで現在のページネーションの番号のデータが逐一取得できるようになっています。