📑

react-tableの実装(カスタム、ソート、フィルター、ページネーション)

2023/05/03に公開

react-tableについて

ReactでテーブルのUIを作成するのにおいて必要な機能をまとめてくれているライブラリになります。
Headless UIのため、デザインは自身で作成をする形になります。(今回のサンプルではMUIを使用)
react-table npmへのリンク

動作環境

@emotion/react: 11.10.6,
@emotion/styled: 11.10.6,
@mui/icons-material: 5.11.11,
@mui/material: 5.11.15,
@tanstack/react-table: 8.8.5,
react: 18.2.0,

使用した機能

  • headerのカスタム
  • cellのカスタム
  • フィルタリング
  • ソート
  • ページネーション

使用した機能の説明(header、cellのカスタム)

columnsの設定により対応可能です。項目によってソートの可否を分けたかったため、ソート可能なものとそうでないものでheaderを変更しています。
react-tableではカラムの定義を行うのですが、その時にheaderに表示させたい内容を指定することで対応可能です。(cellも同じ要領です)

  // getCustomHeader、getCustomBodyはJSXを返す
  const COLUMNS: ColumnDef<any>[] = [
    {
      header: (props) => getCustomHeader("発注日", true, props),
      accessorKey: "orderDate",
      cell: (props) => getCustomBody(props, "center", false),
      filterFn: (row, id) => dateAndNumberFilter(row, id)
    },

使用した機能の説明(フィルタリング)

これもcolumnsの設定で対応可能です。filterFnでは、行ごとのデータ(row)を受け取ることが可能なので、それを使用して抽出条件を記載します。
※booleanを返却(trueは表示、falseは非表示)

使用した機能の説明(ソート)

react-tableのoptionにstate.sortingで設定します。
※{ id: string, desc: boolean }[]の形式

    const table = useReactTable({
    data: data,
    columns: COLUMNS,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    state: {
      sorting: sorting,
      columnFilters: filterConditions,
      pagination: {
        pageIndex: pageSetting.pageIndex,
        pageSize: pageSetting.pageSize
      }
    }
  });

使用した機能の説明(ページネーション)

react-tableのoptionにstate.paginationで設定します。
※pageIndexは表示させたいページを設定。pageSizeは1ページ毎の表示件数を設定。

コードサンプル(全量)

今回試してみたコードの全量になります。

import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import {
  Box,
  Button,
  MenuItem,
  Select,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableRow,
  TableSortLabel,
  Typography,
  TextField
} from "@mui/material";
import { common } from "@mui/material/colors";
import {
  CellContext,
  ColumnDef,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  HeaderContext,
  Row,
  SortingState,
  useReactTable
} from "@tanstack/react-table";
import React, { useState } from "react";

const data = [
  {
    orderDate: "2023/04/03",
    maker: "メーカー1",
    item: "テスト部品1",
    price: 1000
  },
  {
    orderDate: "2023/04/04",
    maker: "メーカー2",
    item: "テスト部品2",
    price: 1001
  },
  {
    orderDate: "2023/04/05",
    maker: "メーカー3",
    item: "テスト部品3",
    price: 1002
  },
  {
    orderDate: "2023/04/06",
    maker: "メーカー4",
    item: "テスト部品4",
    price: 1003
  }
];

export const App = () => {
  const [filterConditions, setFilterConditions] = useState<
    { id: string; value: string | number }[]
  >([]);
  const [sorting, setSorting] = React.useState<SortingState>([
    { id: "orderDate", desc: true }
  ]);
  const [pageSetting, setPageSetting] = useState({
    pageIndex: 0,
    pageSize: 2
  });

  const getCustomHeader = (
    name: string,
    isSortable: boolean,
    props: HeaderContext<any, unknown>
  ) =>
    isSortable ? (
      <TableSortLabel
        onClick={() => {
          if (props.header.id === sorting[0].id) {
            setSorting([{ ...sorting[0], desc: !sorting[0].desc }]);
          } else {
            setSorting([{ id: props.header.id, desc: true }]);
          }
        }}
        IconComponent={() => (
          <ArrowDropDownIcon
            sx={{
              fontSize: "2rem",
              color: sorting[0].id === props.header.id ? common.white : "grey",
              transform:
                sorting[0].desc === true && sorting[0].id === props.header.id
                  ? "rotate(180deg)"
                  : "rotate(0)"
            }}
          />
        )}
      >
        <Typography color="common.white" sx={{ fontWeight: "bold" }}>
          {name}
        </Typography>
      </TableSortLabel>
    ) : (
      <Typography color="common.white" sx={{ fontWeight: "bold" }}>
        {name}
      </Typography>
    );
  const getCustomBody = (
    props: CellContext<any, unknown>,
    align: "center" | "right" | "left",
    isCurrency: boolean
  ) =>
    isCurrency ? (
      <TableCell align={align} key={props.cell.id}>
        <Typography>
          {props.cell.getValue<number>().toLocaleString("ja-JP", {
            style: "currency",
            currency: "JPY"
          })}
        </Typography>
      </TableCell>
    ) : (
      <TableCell align={align} key={props.cell.id}>
        <Typography>{props.cell.getValue<string>()}</Typography>
      </TableCell>
    );

  const dateAndNumberFilter = (row: Row<any>, id: string) => {
    const condition = filterConditions.find(
      (condition) => condition.id === id && condition.value
    );

    if (!condition || condition.value === null) {
      return true;
    }

    let dataVal: number;
    let conditionVal: number;

    if (typeof condition.value === "string") {
      try {
        dataVal = new Date(row.getValue(id) as string).getTime();
        conditionVal = new Date(condition.value.replace("-", "/")).getTime();
      } catch {
        return true;
      }
    } else {
      dataVal = row.getValue(id) as number;
      conditionVal = condition.value as number;
    }

    if (dataVal === conditionVal) {
      return true;
    } else {
      return false;
    }
  };

  const stringFilter = (row: Row<any>, id: string) => {
    const condition = filterConditions.find(
      (condition) => condition.id === id && condition.value
    );

    if (
      !condition ||
      condition.value === null ||
      typeof condition.value !== "string"
    ) {
      return true;
    }

    if ((row.getValue(id) as string).includes(condition.value)) {
      return true;
    } else {
      return false;
    }
  };

  const onChangeCondition = (id: string, value: string | number) => {
    setFilterConditions([
      ...filterConditions.filter((condition) => condition.id !== id),
      { id, value }
    ]);
  };

  // globalFilterもある
  // globalFilter特定の検索ワードが存在するかどうかでフィルター
  const COLUMNS: ColumnDef<any>[] = [
    {
      header: (props) => getCustomHeader("発注日", true, props),
      accessorKey: "orderDate",
      cell: (props) => getCustomBody(props, "center", false),
      filterFn: (row, id) => dateAndNumberFilter(row, id)
    },
    {
      header: (props) => getCustomHeader("メーカー", false, props),
      accessorKey: "maker",
      cell: (props) => getCustomBody(props, "left", false),
      filterFn: (row, id) => stringFilter(row, id)
    },
    {
      header: (props) => getCustomHeader("部品名", false, props),
      accessorKey: "item",
      cell: (props) => getCustomBody(props, "left", false),
      filterFn: (row, id) => stringFilter(row, id)
    },
    {
      header: (props) => getCustomHeader("金額", true, props),
      accessorKey: "price",
      cell: (props) => getCustomBody(props, "right", true),
      filterFn: (row, id) => dateAndNumberFilter(row, id)
    }
  ];

  const table = useReactTable({
    data: data,
    columns: COLUMNS,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    state: {
      sorting: sorting,
      columnFilters: filterConditions,
      pagination: {
        pageIndex: pageSetting.pageIndex,
        pageSize: pageSetting.pageSize
      }
    }
  });

  return (
    <Box m={2}>
      <Box>
        <TextField
          type="date"
          label="発注日"
          InputLabelProps={{ shrink: true }}
          variant="standard"
          defaultValue=""
          value={
            filterConditions.find((condition) => condition.id === "orderDate")
              ?.value ?? ""
          }
          onChange={(e) => {
            onChangeCondition("orderDate", e.target.value);
          }}
        />
      </Box>
      <Box mt={2}>
        <TextField
          label="メーカー"
          InputLabelProps={{ shrink: true }}
          variant="standard"
          value={
            filterConditions.find((condition) => condition.id === "maker")
              ?.value ?? ""
          }
          onChange={(e) => {
            onChangeCondition("maker", e.target.value);
          }}
        />
      </Box>

      <Box mt={2}>
        <TextField
          label="部品名"
          InputLabelProps={{ shrink: true }}
          variant="standard"
          value={
            filterConditions.find((condition) => condition.id === "item")
              ?.value ?? ""
          }
          onChange={(e) => {
            onChangeCondition("item", e.target.value);
          }}
        />
      </Box>

      <Box mt={2}>
        <TextField
          type="number"
          label="金額"
          InputLabelProps={{ shrink: true }}
          variant="standard"
          value={
            filterConditions.find((condition) => condition.id === "price")
              ?.value ?? ""
          }
          onChange={(e) => {
            const num = Number(e.target.value);
            if (isNaN(num)) return;
            onChangeCondition("price", num);
          }}
        />
      </Box>

      <Box sx={{ overflowX: "auto" }}>
        <Table
          sx={{
            marginTop: "1rem"
          }}
        >
          <TableHead sx={{ backgroundColor: "blue" }}>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <TableCell align="center" key={header.id}>
                    {flexRender(
                      header.column.columnDef.header,
                      header.getContext()
                    )}
                  </TableCell>
                ))}
              </TableRow>
            ))}
          </TableHead>
          <TableBody>
            {table.getRowModel().rows.map((row) => {
              return (
                <TableRow key={row.id}>
                  {row.getVisibleCells().map((cell) => (
                    <React.Fragment key={cell.id}>
                      {flexRender(
                        cell.column.columnDef.cell,
                        cell.getContext()
                      )}
                    </React.Fragment>
                  ))}
                </TableRow>
              );
            })}
          </TableBody>
        </Table>
      </Box>

      <Box
        sx={{
          textAlign: "center",
          marginTop: "2rem",
          marginBottom: "2rem"
        }}
      >
        <Button
          disabled={!table.getCanPreviousPage()}
          onClick={() =>
            setPageSetting((old) => {
              return { ...pageSetting, pageIndex: old.pageIndex - 1 };
            })
          }
        >
          前のページ
        </Button>
        <Select
          value={pageSetting.pageIndex}
          disabled={table.getPageCount() === 0}
          onChange={(e) =>
            setPageSetting({
              ...pageSetting,
              pageIndex: e.target.value as number
            })
          }
        >
          {[...Array(table.getPageCount())].map((_, index) => (
            <MenuItem key={index} value={index}>
              {table.getPageCount() !== 0
                ? `${index + 1}/${table.getPageCount()}`
                : 0}
            </MenuItem>
          ))}
        </Select>
        <Button
          disabled={!table.getCanNextPage()}
          onClick={() =>
            setPageSetting((old) => {
              return { ...pageSetting, pageIndex: old.pageIndex + 1 };
            })
          }
        >
          次のページ
        </Button>
      </Box>
    </Box>
  );
};

イメージ

入力部分は非常に簡易ですが、動作イメージです。

Discussion