📚

Next.js / MUI (Material UI) でテーブルページネーション実装

2023/12/23に公開

はじめに

今回は、MUIを用いてテーブルページネーションを実装したのですが、それをMUIのPaginationコンポーネントのUIにする方法について解説します。

MUI側でサンプルとして用意されているUIでも、それなりにイケているのですが、ページネーションのNextとPreviousだけでなく内容自体をPaginationコンポーネントの「1・2・3・・・10」のようにしたい場面はよくあるかと思います。

正直なところ、tailwindCssでゴリゴリ書いていってもいいのですが、業務でMUIを使用し、上記実装をする必要性があったため、そのようにしました。

完成UI

  • 1ページ目
    UI1

  • 2ページ目
    UI2

環境

  • 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

実装

それでは、実装の方を見ていきましょう。
まずは、完成したコードを見せて、その後に実装手順を解説します。

完成コード

page.tsx
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
StickyHeadTable.tsx
'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を導入します。

fish
yarn add @mui/material @emotion/react @emotion/styled

また、今回の実装では@material-ui/coreも使用するため、こちらもinstallします。

fish
yarn add @material-ui/core

導入方法については以下のドキュメントを参照してください。
https://mui.com/material-ui/getting-started/installation/

データフェッチ

続いてテーブルに渡すデータを取得します。

page.tsx
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からデータを取得します。
https://jsonplaceholder.typicode.com/

テーブルの実装

続いて、テーブルの実装ですが、ここは細かく見ていきましょう。

StrickyHeadTable.tsx
'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',
    },
  },
})

ポイントとなるスタイルは以下です。

  • rootcontenttransform: 'rotateX(180deg)'
  • strickeyHeader
  • roundedPaginationItemmenuItem

それぞれ見ていくと、rootcontenttransform: 'rotateX(180deg)'ではテーブルに表示するスクロールバー(業務のデザインで横スクロール)のスタイルを作成しています。
これを実装することで、スクロールバーをテーブルの上部にもってくることができます。

親と子の要素、つまりテーブルの親要素と実際のテーブルの要素にあてることで実現しているのですが、内部的には親を半回転させるとスクロールバー自体は上部にもってこれます。ただ、子も半回転するので、子要素の文字等の表示が鏡写しのようになります。
そのため、さらに子要素を半回転させているというスタイリングをしています。
https://dev.classmethod.jp/articles/css_scrollbar_adjustment/

次に、strickeyHeaderですが、これは先頭列を固定表示とするスタイリングです。
基本的にはposition: 'stricky'とするだけでいいので簡単です。

最後のroundedPaginationItemmenuItemですが、これは既存のMUIのクラスに付与されているスタイルを上書きしています。
後述しますが、ドキュメントを見てもらうとわかりますが、Paginationコンポーネントはデフォルト状態でborder-radiusが効いています。
ただ、実際に画面表示するとborder-radius: 0となっていたので、既存のMUIのクラスを上書きしました。
注意点としては、上書きをする際は!importantが必須なので、そこだけ注意が必要です。
https://mui.com/material-ui/react-pagination/

コンポーネントのロジック

次に、コンポーネントのロジックですが、以下のようになっています。

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では、前述したスタイルを格納しています。

次に、pagerowPerPageですが、pageは現在のページを示し、rowPerPageは1ページあたりの表示数を示します。

続いて、各関数に入ります。
各関数の処理内容は以下のとおりです。

  • handleChangePage: ページネーションのページを動かす(テーブルの表示は変わらず、ページネーションのボタンの部分が変わる)
    newPage - 1としているのはTablePaginationPaginationの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にTablePaginationrowsPerPageOptionsの値が入る)

以上がコンポーネントのロジック部分の解説です。
次が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' }}
    />
  )}
/>

基本的には、コメントに書いてあるとおりです。
解説する部分はslotPropsActioncCmponentです。

まず、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を作成することができ、実際にテーブルのページネーションを実装することができます。

より正確な解説を知りたい方はドキュメントやライブラリの型定義を解読してみてください。
https://mui.com/material-ui/react-table/
https://mui.com/base-ui/react-table-pagination/
https://mui.com/material-ui/api/table-pagination/

まとめ

MUIはドキュメントのサンプルコードのみでイケてるUIを作ることができますし、コンポーネントの内容やスタイリングを変える以外の軽い編集程度なら、ドキュメントを簡単にできるのだと感じました。

ただ、今回のようにコンポーネントの中身を編集するとなった場合、型定義まで追う必要があるため、それなりの労力が必要でした。
ということで、TypeScriptの理解が不足している点が明確になったので結果的には良かったかなと思います。

余談ですが、改めてTypeScriptのライブラリを作成できるエンジニアは尊敬できると感じた経験となりました。

最後まで読んでくださりありがとうございます。
どなたかの参考になれば幸いです。

参考文献

https://mui.com/material-ui/getting-started/installation/
https://mui.com/material-ui/react-table/
https://mui.com/material-ui/api/pagination/
https://mui.com/base-ui/react-table-pagination/
https://mui.com/material-ui/api/table-pagination/
https://mui.com/material-ui/react-pagination/
https://jsonplaceholder.typicode.com/

Discussion