Next.js / MUI (Material UI) でテーブルページネーション実装
はじめに
今回は、MUIを用いてテーブルページネーションを実装したのですが、それをMUIのPagination
コンポーネントのUIにする方法について解説します。
MUI側でサンプルとして用意されているUIでも、それなりにイケているのですが、ページネーションのNextとPreviousだけでなく内容自体をPagination
コンポーネントの「1・2・3・・・10」のようにしたい場面はよくあるかと思います。
正直なところ、tailwindCss
でゴリゴリ書いていってもいいのですが、業務でMUIを使用し、上記実装をする必要性があったため、そのようにしました。
完成UI
-
1ページ目
-
2ページ目
環境
- Next 14.0.4
- @mui/material 5.15.1
- @material-ui/core 4.12.4
- @emotion/react 11.11.1
- @emotion/styled 11.11.0
実装
それでは、実装の方を見ていきましょう。
まずは、完成したコードを見せて、その後に実装手順を解説します。
完成コード
import StickyHeadTable from '@/Components/StrickyHeadTable'
const Home = async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
const posts = await res.json()
return <StickyHeadTable rows={posts} />
}
export default Home
'use client'
import Checkbox from '@material-ui/core/Checkbox'
import Paper from '@material-ui/core/Paper'
import { makeStyles } from '@material-ui/core/styles'
import TableBody from '@material-ui/core/TableBody'
import TableCell from '@material-ui/core/TableCell'
import TableContainer from '@material-ui/core/TableContainer'
import TableHead from '@material-ui/core/TableHead'
import { Pagination } from '@mui/material'
import Table from '@mui/material/Table'
import TablePagination from '@mui/material/TablePagination'
import TableRow from '@mui/material/TableRow'
import React, { useCallback, useState } from 'react'
const useStyles = makeStyles({
root: {
width: '100%',
overflowX: 'auto',
transform: 'rotateX(180deg)',
},
content: {
transform: 'rotateX(180deg)',
},
stickyHeader: {
position: 'sticky',
left: 0,
zIndex: 1,
backgroundColor: '#fff',
},
cell: {
minWidth: 400,
},
roundedPaginationItem: {
'& .MuiButtonBase-root': {
borderRadius: '50% !important',
},
},
menuItem: {
'& .MuiButtonBase-root': {
padding: '10px 0 !important',
},
},
})
type Props = {
rows: { id: number; userId: number; title: string; body: string }[]
}
const StickyHeadTable: React.FC<Props> = ({ rows }) => {
const classes = useStyles()
const [page, setPage] = useState(0)
const [rowsPerPage, setRowsPerPage] = useState(10)
const handleChangePage = useCallback((event: unknown, newPage: number) => {
setPage(newPage - 1)
}, [])
const handleChangeTablePage = useCallback((event: unknown, newPage: number) => {
setPage(newPage)
}, [])
const handleChangeRowsPerPage = useCallback((
event: React.ChangeEvent<HTMLInputElement>,
) => {
setRowsPerPage(Number(event.target.value))
setPage(0)
}, [])
return (
<>
<TablePagination
rowsPerPageOptions={[10, 25, 100]}
component="div"
slotProps={{
select: {
MenuProps: {
MenuListProps: {
sx: {
display: 'flex',
flexDirection: 'column',
},
classes: { root: classes.menuItem },
},
},
},
}}
count={rows.length}
rowsPerPage={rowsPerPage}
labelRowsPerPage="1ページあたりの表示数"
page={page}
onPageChange={handleChangeTablePage}
onRowsPerPageChange={handleChangeRowsPerPage}
ActionsComponent={() => (
<Pagination
className={classes.roundedPaginationItem}
count={Math.ceil(rows.length / rowsPerPage)}
page={page + 1}
onChange={handleChangePage}
showFirstButton
showLastButton
color="primary"
sx={{ ml: '10px', width: '750px' }}
/>
)}
/>
<Paper className={classes.root}>
<TableContainer>
<Table
stickyHeader
aria-label="sticky table"
className={classes.content}
>
<TableHead>
<TableRow>
<TableCell />
<TableCell>Id</TableCell>
<TableCell align="left">UserId</TableCell>
<TableCell align="right">Title</TableCell>
<TableCell align="right">Body</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((row) => (
<TableRow
key={row.id}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell
component="th"
scope="row"
className={classes.stickyHeader}
>
<Checkbox />
</TableCell>
<TableCell
component="th"
scope="row"
className={classes.cell}
>
{row.id}
</TableCell>
<TableCell
component="th"
scope="row"
className={classes.cell}
>
{row.userId}
</TableCell>
<TableCell align="left" className={classes.cell}>
{row.title}
</TableCell>
<TableCell align="left" className={classes.cell}>
{row.body}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</>
)
}
export default StickyHeadTable
MUI 導入
以下のコマンドを実行し、MUIを導入します。
yarn add @mui/material @emotion/react @emotion/styled
また、今回の実装では@material-ui/core
も使用するため、こちらもinstallします。
yarn add @material-ui/core
導入方法については以下のドキュメントを参照してください。
データフェッチ
続いてテーブルに渡すデータを取得します。
import StickyHeadTable from '@/Components/StrickyHeadTable'
const Home = async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts')
const posts = await res.json()
return <StickyHeadTable rows={posts} />
}
export default Home
今回はjsonplaceholder
のpostsからデータを取得します。
テーブルの実装
続いて、テーブルの実装ですが、ここは細かく見ていきましょう。
'use client'
import Checkbox from '@material-ui/core/Checkbox'
import Paper from '@material-ui/core/Paper'
import { makeStyles } from '@material-ui/core/styles'
import TableBody from '@material-ui/core/TableBody'
import TableCell from '@material-ui/core/TableCell'
import TableContainer from '@material-ui/core/TableContainer'
import TableHead from '@material-ui/core/TableHead'
import { Pagination } from '@mui/material'
import Table from '@mui/material/Table'
import TablePagination from '@mui/material/TablePagination'
import TableRow from '@mui/material/TableRow'
import React, { useState } from 'react'
const useStyles = makeStyles({
root: {
width: '100%',
overflowX: 'auto',
transform: 'rotateX(180deg)',
},
content: {
transform: 'rotateX(180deg)',
},
stickyHeader: {
position: 'sticky',
left: 0,
zIndex: 1,
backgroundColor: '#fff',
},
cell: {
minWidth: 400,
},
roundedPaginationItem: {
'& .MuiButtonBase-root': {
borderRadius: '50% !important',
},
},
menuItem: {
'& .MuiButtonBase-root': {
padding: '10px 0 !important',
},
},
})
type Props = {
rows: { id: number; userId: number; title: string; body: string }[]
}
const StickyHeadTable: React.FC<Props> = ({ rows }) => {
const classes = useStyles()
const [page, setPage] = useState(0)
const [rowsPerPage, setRowsPerPage] = useState(10)
const handleChangePage = useCallback((event: unknown, newPage: number) => {
setPage(newPage - 1)
}, [])
const handleChangeTablePage = useCallback((event: unknown, newPage: number) => {
setPage(newPage)
}, [])
const handleChangeRowsPerPage = useCallback((
event: React.ChangeEvent<HTMLInputElement>,
) => {
setRowsPerPage(Number(event.target.value))
setPage(0)
}, [])
return (
<>
<TablePagination
rowsPerPageOptions={[10, 25, 100]}
component="div"
slotProps={{
select: {
MenuProps: {
MenuListProps: {
sx: {
display: 'flex',
flexDirection: 'column',
},
classes: { root: classes.menuItem },
},
},
},
}}
count={rows.length}
rowsPerPage={rowsPerPage}
labelRowsPerPage="1ページあたりの表示数"
page={page}
onPageChange={handleChangeTablePage}
onRowsPerPageChange={handleChangeRowsPerPage}
ActionsComponent={() => (
<Pagination
className={classes.roundedPaginationItem}
count={Math.ceil(rows.length / rowsPerPage)}
page={page + 1}
onChange={handleChangePage}
showFirstButton
showLastButton
color="primary"
sx={{ ml: '10px', width: '750px' }}
/>
)}
/>
<Paper className={classes.root}>
<TableContainer>
<Table
stickyHeader
aria-label="sticky table"
className={classes.content}
>
<TableHead>
<TableRow>
<TableCell />
<TableCell>Id</TableCell>
<TableCell align="left">UserId</TableCell>
<TableCell align="right">Title</TableCell>
<TableCell align="right">Body</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((row) => (
<TableRow
key={row.id}
sx={{ '&:last-child td, &:last-child th': { border: 0 } }}
>
<TableCell
component="th"
scope="row"
className={classes.stickyHeader}
>
<Checkbox />
</TableCell>
<TableCell
component="th"
scope="row"
className={classes.cell}
>
{row.id}
</TableCell>
<TableCell
component="th"
scope="row"
className={classes.cell}
>
{row.userId}
</TableCell>
<TableCell align="left" className={classes.cell}>
{row.title}
</TableCell>
<TableCell align="left" className={classes.cell}>
{row.body}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Paper>
</>
)
}
export default StickyHeadTable
スタイルを定義
まず、makeStyles
でコンポーネントで使用するスタイルを定義します。
makeStyles
はMUIで提供されているスタイルを定義するためのフックで、これをコンポーネントでclassName={classes.foo}
などとしてスタイルをあてることができます。
import { makeStyles } from '@material-ui/core/styles'
const useStyles = makeStyles({
root: {
width: '100%',
overflowX: 'auto',
transform: 'rotateX(180deg)',
},
content: {
transform: 'rotateX(180deg)',
},
stickyHeader: {
position: 'sticky',
left: 0,
zIndex: 1,
backgroundColor: '#fff',
},
cell: {
minWidth: 400,
},
roundedPaginationItem: {
'& .MuiButtonBase-root': {
borderRadius: '50% !important',
},
},
})
ポイントとなるスタイルは以下です。
-
root
とcontent
のtransform: 'rotateX(180deg)'
strickeyHeader
-
roundedPaginationItem
・menuItem
それぞれ見ていくと、root
とcontent
のtransform: 'rotateX(180deg)'
ではテーブルに表示するスクロールバー(業務のデザインで横スクロール)のスタイルを作成しています。
これを実装することで、スクロールバーをテーブルの上部にもってくることができます。
親と子の要素、つまりテーブルの親要素と実際のテーブルの要素にあてることで実現しているのですが、内部的には親を半回転させるとスクロールバー自体は上部にもってこれます。ただ、子も半回転するので、子要素の文字等の表示が鏡写しのようになります。
そのため、さらに子要素を半回転させているというスタイリングをしています。
次に、strickeyHeader
ですが、これは先頭列を固定表示とするスタイリングです。
基本的にはposition: 'stricky'
とするだけでいいので簡単です。
最後のroundedPaginationItem
とmenuItem
ですが、これは既存のMUIのクラスに付与されているスタイルを上書きしています。
後述しますが、ドキュメントを見てもらうとわかりますが、Pagination
コンポーネントはデフォルト状態でborder-radius
が効いています。
ただ、実際に画面表示するとborder-radius: 0
となっていたので、既存のMUIのクラスを上書きしました。
注意点としては、上書きをする際は!important
が必須なので、そこだけ注意が必要です。
コンポーネントのロジック
次に、コンポーネントのロジックですが、以下のようになっています。
const classes = useStyles()
const [page, setPage] = useState(0)
const [rowsPerPage, setRowsPerPage] = useState(10)
const handleChangePage = useCallback((event: unknown, newPage: number) => {
setPage(newPage - 1)
}, [])
const handleChangeTablePage = useCallback((event: unknown, newPage: number) => {
setPage(newPage)
}, [])
const handleChangeRowsPerPage = useCallback((
event: React.ChangeEvent<HTMLInputElement>,
) => {
setRowsPerPage(Number(event.target.value))
setPage(0)
}, [])
順を追って解説します。
まず、classes
では、前述したスタイルを格納しています。
次に、page
とrowPerPage
ですが、page
は現在のページを示し、rowPerPage
は1ページあたりの表示数を示します。
続いて、各関数に入ります。
各関数の処理内容は以下のとおりです。
-
handleChangePage
: ページネーションのページを動かす(テーブルの表示は変わらず、ページネーションのボタンの部分が変わる)
※newPage - 1
としているのはTablePagination
とPagination
のPropsの違いや、slice
が関連しているためです -
handleChangeTablePage
: テーブルのページが変わる
※ 以下のようにTableBodyの要素を表示しています。
sliceを使用しているので、表示する要素数を10とし、2ページ目にページネーションしたら、slice(10, 20).map()
となり、配列の10番目から19番目の要素を取得します。
rows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((row) => {})
-
handleChangeRowsPage
: 表示する要素数のセレクトボックスの値に応じてテーブルに表示数を変更する関数(event.target.valueにTablePagination
のrowsPerPageOptions
の値が入る)
以上がコンポーネントのロジック部分の解説です。
次がJSXの部分の解説です。
JSX
JSX部分ですが、Tableの部分は見たらわかるかと思うので、TablePagination
のみ解説します。
<TablePagination
rowsPerPageOptions={[10, 25, 100]}
component="div"
// セレクトボックスのスタイルを上書き
slotProps={{
select: {
MenuProps: {
MenuListProps: {
sx: {
display: 'flex',
flexDirection: 'column',
},
classes: { root: classes.menuItem },
},
},
},
}}
// 「1-10 of 100」 の 「100」の部分
count={rows.length}
// セレクトボックスの値
rowsPerPage={rowsPerPage}
labelRowsPerPage="1ページあたりの表示数"
// 「1-10 of 100」の部分 「1-10」の部分
page={page}
// ページが変更されたときに発生する処理
onPageChange={handleChangeTablePage}
// ページあたりの行数(セレクトボックスの値)が変更されたときに発生する処理
onRowsPerPageChange={handleChangeRowsPerPage}
ActionsComponent={() => (
<Pagination
className={classes.roundedPaginationItem}
// ページのトータルのカウント数(例えば、100行のテーブルで表示数10の場合は10となり、ページネーションボタンが10まで表示される)
count={Math.ceil(rows.length / rowsPerPage)}
page={page + 1}
// ページが変更されたときに発生する処理
onChange={handleChangePage}
showFirstButton
showLastButton
color="primary"
sx={{ ml: '10px', width: '750px' }}
/>
)}
/>
基本的には、コメントに書いてあるとおりです。
解説する部分はslotProps
とActioncCmponent
です。
まず、slotProps
ですが、これを使用するとTablePagination
の各部品やセレクトボックスのスタイル等をカスタマイズすることができます。
以下が型定義になっているので、このような型に従い実装する必要があるようです。
私はスタイルの修正くらいしかやっていない(おそらく、そのくらいしか使用しないと思われる)のですが、他に用途があればコメント等くださると嬉しいです。
{
actions?:
{
firstButton?: object,
firstButtonIcon?: object,
lastButton?: object,
lastButtonIcon?: object,
nextButton?: object,
nextButtonIcon?: object,
previousButton?: object,
previousButtonIcon?: object
},
select?: object
}
次にActionComponent
ですが、これをを使用するとデフォルトのTablePagination
のUIの内容を指定したコンポーネントやHTMLにすることができます。
今回の場合は、まさに「The Pagination!」的なUIにするためにMUIのPagination
をコールバックに渡しています。
これにより、完成UIのようなUIを作成することができ、実際にテーブルのページネーションを実装することができます。
より正確な解説を知りたい方はドキュメントやライブラリの型定義を解読してみてください。
まとめ
MUIはドキュメントのサンプルコードのみでイケてるUIを作ることができますし、コンポーネントの内容やスタイリングを変える以外の軽い編集程度なら、ドキュメントを簡単にできるのだと感じました。
ただ、今回のようにコンポーネントの中身を編集するとなった場合、型定義まで追う必要があるため、それなりの労力が必要でした。
ということで、TypeScriptの理解が不足している点が明確になったので結果的には良かったかなと思います。
余談ですが、改めてTypeScriptのライブラリを作成できるエンジニアは尊敬できると感じた経験となりました。
最後まで読んでくださりありがとうございます。
どなたかの参考になれば幸いです。
参考文献
Discussion