Reactで作った自作のページャの表示ロジックをライブラリのカスタムフックに差し替えてみた

目次

はじめに

「ライブラリのページャを使ってるんだけど、見た目をカスタマイズしたいな・・・」
「でもカスタマイズの仕方わからないな・・・」
「ページャを作るロジックだけ使えればいいんだけどな・・・」

そう思ったことはありませんか?

それ、MUI の usePagination を使えば実現します。

この記事では、自作のページャの表示ロジックを MUI が提供している usePagination に差し替えた方法をシェアします。

ページャをきちんと作ろうと思うと、実はけっこう大変です。

ページ数が多いときはレイアウトが崩れないよう、溢れたページを省略表示しないといけません。
省略表示の方法も、現在ページによって微妙に異なります。

勉強がてら自分で作ってみるのもいいですが、「ページャはさっさと片付けてもっと別のことに集中したい」という方はライブラリを使うべきでしょう。
使えるものは使ったほうがよいです。

ただし、ライブラリを使うと気にしなくてはならないのが「見た目の統一」です。

独自のデザインの画面の中に、ポツンとライブラリのページャが使われていたら、きっとそこだけ異質に見えてしまうでしょう。

見た目をちゃちゃっとカスタマイズできればよいですが、難しい場合も考えられます。

そんなときは、この記事で紹介する「表示ロジックだけライブラリを使う方法」を試してみてください。

面倒な表示ロジックだけはライブラリを使い、見た目は自分好みに作る。

そんないいとこ取りの方法をご紹介します。

実物はこちら

あわせて読みたい
React Recipe Reactアプリのレシピ集

リポジトリはこちら

GitHub
GitHub - hakoratory/react-recipe Contribute to hakoratory/react-recipe development by creating an account on GitHub.

概要

この記事を読むことで、以下のような画面が作れるようになります。

実装

先にコード全容です。

'use client'

import '../style.css'
import {Box, Button} from "@mui/material";
import {Input} from "@/components/input";
import {useTextInput} from "@/hooks/use-text-input";
import {tableUsers} from "@/data/data";
import {
  FetchTableUsersWithPageRequestType,
  FetchTableUsersWithPageResponseType,
  PageButtonProps,
  RichPagerProps,
  TableUser
} from "@/types/table";
import {ChangeEvent, CSSProperties, useEffect, useState} from "react";
import usePagination, {UsePaginationItem} from "@mui/material/usePagination";

const fetchUsers = async (param: FetchTableUsersWithPageRequestType): Promise<FetchTableUsersWithPageResponseType> => {
  if (process.env.NEXT_PUBLIC_USE_MOCK_DATA === 'true') {
    const filteredUsers = tableUsers.filter(user => user.name.indexOf(param.name ?? '') > -1)
    const from = (param.page - 1) * param.pageSize
    const to = param.page * param.pageSize
    return {
      users: filteredUsers.slice(from, to),
      totalCount: tableUsers.length,
    }
  } else {
    const response = await fetch('/api/table/users-with-page', {
      method: 'POST',
      body: JSON.stringify(param)
    })
    return await response.json()
  }
}

const PageButton = ({children, onClick, disabled, selected}: PageButtonProps) => {
  const pageButtonStyle: CSSProperties = {
    border: '1px solid #000',
    minWidth: '35px',
    textAlign: 'center',
    padding: "5px 0",
    marginLeft: '8px',
  }

  return (
    <Button
      style={{
        ...pageButtonStyle,
        backgroundColor: selected ? '#f0f8ff' : 'transparent'
      }}
      onClick={onClick}
      disabled={disabled}
    >
      {children}
    </Button>
  )
}

const Pager = ({totalCount, page, pageSize, onPageChange}: RichPagerProps) => {
  const totalPageCount = Math.ceil(totalCount / pageSize)
  // 表示中の件数表示 from - to
  const from = (page - 1) * pageSize + 1
  const to = page < totalPageCount ? page * pageSize : totalCount

  const {items} = usePagination({
    count: totalPageCount,
    page: page,
    onChange: onPageChange
  })

  const getButtonLabel = (item: UsePaginationItem) => {
    switch (item.type) {
      case 'first':
        return '<<'
      case 'previous':
        return '<'
      case 'start-ellipsis':
      case 'end-ellipsis':
        return '...'
      case 'page':
        return item.page
      case 'next':
        return '>'
      case 'last':
        return '>>'
    }
  }

  return (
    <div style={{display: 'flex', justifyContent: 'flex-end', marginTop: '1rem'}} className="page-buttons">
      {
        totalCount > 0 &&
        <div style={{marginRight: '16px', padding: '5px 0'}}>
          {from} - {to} of {totalCount}
        </div>
      }
      {
        items.map((item, index) => (
          <PageButton
            key={index}
            onClick={item.onClick}
            disabled={item.disabled}
            selected={item.selected}
          >
            {getButtonLabel(item)}
          </PageButton>
        ))
      }
    </div>
  )
}

export default function TableScratchWithPagerRich() {
  const [searchWord, setSearchWord] = useTextInput()
  const [tableUserData, setTableUserData] = useState<Array<TableUser>>([])
  const [totalCount, setTotalCount] = useState<number>(0)
  const [currentPage, setCurrentPage] = useState<number>(1)
  const [pageSize, setPageSize] = useState<number>(5)

  useEffect(() => {
    (async () => {
      const newUsers = await fetchUsers({name: null, page: 1, pageSize})
      setTableUserData(newUsers.users)
      setTotalCount(newUsers.totalCount)
      setCurrentPage(1)
    })()
  }, [])

  const handleSearch = async (event: ChangeEvent<unknown>, newPage: number) => {
    const newUsers = await fetchUsers({name: searchWord.value, page: newPage, pageSize})
    setTableUserData(newUsers.users)
    setTotalCount(newUsers.totalCount)
    setCurrentPage(newPage)
  }

  return (
    <main>
      <Box m={2}>
        <Box display="flex">
          <Box>
            <Input label="名前" errors={searchWord.errors} onChange={(e) => setSearchWord(e.target.value)}/>
          </Box>
          <Box ml={3} pt={3}>
            <Button
              type="button"
              variant="contained"
              disabled={searchWord.errors.length > 0}
              onClick={(e) => handleSearch(e, 1)}
            >
              検索
            </Button>
          </Box>
        </Box>
      </Box>
      <Box m={2}>
        <Pager
          totalCount={totalCount}
          page={currentPage}
          pageSize={pageSize}
          onPageChange={handleSearch}
        />
        <table style={{marginTop: '1rem'}}>
          <thead>
          <tr>
            <th>名前</th>
            <th>所属</th>
            <th>好きなもの</th>
          </tr>
          </thead>
          <tbody>
          {
            tableUserData.map((user) => (
              <tr key={user.id}>
                <td>{user.name}</td>
                <td>{user.department}</td>
                <td>{user.favoriteThings}</td>
              </tr>
            ))
          }
          </tbody>
        </table>
        <Pager
          totalCount={totalCount}
          page={currentPage}
          pageSize={pageSize}
          onPageChange={handleSearch}
        />
      </Box>
    </main>
  )
}

各部分の解説

ページャの表示ロジックを usePagination に差し替える

差し替え前の表示ロジックを以下に記載します。

const Pager = ({totalCount, page, pageSize, onPageChange}: PagerProps) => {
  const totalPageCount = Math.ceil(totalCount / pageSize)
  const pageButtonStyle: CSSProperties = {
    border: '1px solid #000',
    width: '30px',
    textAlign: 'center'
  }
  return (
    <div style={{display: 'flex', justifyContent: 'flex-end', marginTop: '1rem'}}>
      {
        // ページ数が2ページ以上のとき、「<」ボタンを表示する
        totalPageCount > 1 &&
        <div
          style={{
            ...pageButtonStyle,
            backgroundColor: page <= 1 ? '#dcdcdc' : 'transparent',
          }}
          onClick={() => {
            if (page <= 1) return
            onPageChange(page - 1)
          }}
        >
          {'<'}
        </div>
      }
      {
        [...Array(totalPageCount)].map((_, index) => (
          index + 1 === page
            ? (
              <div key={index}
                   style={{
                     ...pageButtonStyle,
                     marginLeft: '8px',
                     backgroundColor: '#f0f8ff'
                   }}
              >
                {index + 1}
              </div>
            )
            : (
              <div key={index}
                   style={{
                     ...pageButtonStyle,
                     marginLeft: '8px'
                   }}
                   onClick={() => onPageChange(index + 1)}
              >
                {index + 1}
              </div>
            )
        ))
      }
      {
        // ページ数が2ページ以上のとき、「>」ボタンを表示する
        totalPageCount > 1 &&
        <div
          style={{
            ...pageButtonStyle,
            marginLeft: '8px',
            backgroundColor: page >= totalPageCount ? '#dcdcdc' : 'transparent',
          }}
          onClick={() => {
            if (page >= totalPageCount) return
            onPageChange(page + 1)
          }}
        >
          {'>'}
        </div>
      }
    </div>
  )
}

条件分岐が何個もあり、省略表示の処理も入れられていません。

次に、表示ロジックを usePagination に差し替えたコードを以下に記載します。

const Pager = ({totalCount, page, pageSize, onPageChange}: RichPagerProps) => {
  const totalPageCount = Math.ceil(totalCount / pageSize)
  // 表示中の件数表示 from - to
  const from = (page - 1) * pageSize + 1
  const to = page < totalPageCount ? page * pageSize : totalCount

  const {items} = usePagination({
    count: totalPageCount,
    page: page,
    onChange: onPageChange
  })

  const getButtonLabel = (item: UsePaginationItem) => {
    switch (item.type) {
      case 'first':
        return '<<'
      case 'previous':
        return '<'
      case 'start-ellipsis':
      case 'end-ellipsis':
        return '...'
      case 'page':
        return item.page
      case 'next':
        return '>'
      case 'last':
        return '>>'
    }
  }

  return (
    <div style={{display: 'flex', justifyContent: 'flex-end', marginTop: '1rem'}} className="page-buttons">
      {
        totalCount > 0 &&
        <div style={{marginRight: '16px', padding: '5px 0'}}>
          {from} - {to} of {totalCount}
        </div>
      }
      {
        items.map((item, index) => (
          <PageButton
            key={index}
            onClick={item.onClick}
            disabled={item.disabled}
            selected={item.selected}
          >
            {getButtonLabel(item)}
          </PageButton>
        ))
      }
    </div>
  )
}

ハイライトした箇所が usePagination を使用しているところです。

usePagination を呼ぶことで、ページャを表示するための items が返されます。
items には、省略表示はもちろん、現在ページのフラグ、次に進む、前に戻るなど、ページャに必要な情報が含まれています。
なので、あとは items を使ってページャを表示すればOKです。

とても簡単ですね!

条件分岐がなくなり、コードがスッキリしていることを確認してください。

ページャのボタンの見た目をカスタマイズする

ページャの表示ロジックがクリアできたので、見た目をカスタマイズしていきましょう。

ページャの各ボタンは PageButton コンポーネントで表示するようにしています。

const PageButton = ({children, onClick, disabled, selected}: PageButtonProps) => {
  const pageButtonStyle: CSSProperties = {
    border: '1px solid #000',
    minWidth: '35px',
    textAlign: 'center',
    padding: "5px 0",
    marginLeft: '8px',
  }

  return (
    <Button
      style={{
        ...pageButtonStyle,
        backgroundColor: selected ? '#f0f8ff' : 'transparent'
      }}
      onClick={onClick}
      disabled={disabled}
    >
      {children}
    </Button>
  )
}

以下の設定をしていることを確認してください。

  • スタイル設定:pageButtonStyle でボタンの基本のスタイルを設定しています。
  • 現在ページのスタイル設定:usePagination から返される items には、現在ページかどうかを表す selected というプロパティが含まれています。これが true のとき、ボタンの色を変えてハイライトするようにしています。
  • ボタン非活性のスタイル設定:usePagination から返される items には、ボタンが使用可能かどうかを表す disabled というプロパティが含まれています。例えば、現在ページが 1 のとき、前に戻るボタンは使えないので disabled=true となります。

おわりに

いかがだったでしょうか?

今回は、自作のページャの表示ロジックを MUI が提供している usePagination に差し替える方法を紹介しました。

usePagination は React のカスタムフックという機能で実装されています。

カスタムフックは、React のコードのうちのロジック部分を別関数に分離するためのものです。
ページャの表示ロジックを usePagination に差し替えたことで、差し替え前にあった条件分岐がごっそりなくなったことがおわかりいただけたかと思います。

ページャに限らず何かアプリを作ろうと思ったとき、カスタムフックが提供されていれば同じように活用できるはずです。

「表示ロジックは実装したくないけど、見た目にはこだわりたい」というときは、ライブラリでカスタムフックが提供されていないか、探してみてはいかがでしょうか?

ご自身でアプリを作るときも、カスタムフックで表示ロジックを分離することはいい選択です。
ぜひ試してみてくださいね。

ハコラトリは、Reactを習得したい駆け出しエンジニアを応援しています。

Reactレシピでは、これからもReactで色々なUIを作って紹介していきますので、「こんなUIを作ってみてほしい」「こんな場合はどうすれば?」といったアイディアを募集中です!
コメントお待ちしております。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

28歳のときに音楽家からエンジニアに転向。
WEB系のシステム開発(Java, C#, Vue, React など)に携わっています。

自分らしく力を発揮できて、自信を持って生きられる人を増やすための活動をしています。

コメント

コメントする

目次