🍵

tanstack-table と mui でGroupingされたExpandableなテーブルUIを実装する

2024/12/05に公開

背景

テーブルのデータをグルーピングしてExpandableなUIを実現したいが、実装コストが高くついてしまうケースがままあります。
そこで現在オープンソースで提供されているライブラリをできる限り活用して、この複雑なユーザーインターフェースをざっくり実装するためのソリューションを残します。

解決したい課題

  • 行をグルーピングして、閉じたり開いたりするUIを実現したい
  • 実装コストを下げつつ、経済的なコストも抑えたい

各種Version

  • react: 18.3.1
  • tanstack/react-table: 8.20.5
  • mui/material: 6.1.8

それぞれの導入は、公式を参照ください(記事下部に記載)

tanstack-tableとは

tanstackとは、Typescriptのオープンソースのライブラリを数多く提供してくれている開発者コミュニティです。
最近だとrouterやformもリリースしていましたが、馴染みが多いケースだとtanstack-queryを使用している方も多くいらっしゃるのではないでしょうか

https://github.com/tanstack

TanStack Table は、TS/JS、React、Vue、Solid、Qwik、Svelte 用の強力なテーブルとデータグリッドを構築するためのヘッドレス UIライブラリです。

公式にも記述がある通り、良い感じにテーブルビューを実現するためのユーザーインタラクションのロジックや状態はtanstackが良い感じにしてくれるものの、マークアップは開発者自身で行なってくださいねというライブラリです。
React、Vue、Solid、Svelte、Qwik 用のすぐに使用できるアダプターが提供されており、各種ライブラリへの導入も楽に行うことができます。

実際の画面

実装

import {
  Box,
  IconButton,
  Paper,
  Table,
  TableBody,
  TableCell,
  TableContainer,
  TableHead,
  TableRow,
  Checkbox,
  Typography,
} from "@mui/material";
import {
  type ColumnDef,
  type ExpandedState,
  flexRender,
  getCoreRowModel,
  getExpandedRowModel,
  getPaginationRowModel,
  useReactTable,
} from "@tanstack/react-table";
import { type FC, useMemo, useState } from "react";
import { v4 as uuidv4 } from "uuid";

export const TestTable: FC = () => {
  const [expandedState, setExpandedState] = useState<ExpandedState>(true);

  const columns = useMemo<ColumnDef<GroupViewModel>[]>(
    () => [
      {
        accessorKey: "companyName",
        header: "会社名",
        cell: ({ row, getValue }) => {
          const isExpanded = row.getIsExpanded();
          const canExpand = row.getCanExpand();
          const onToggle = row.getToggleExpandedHandler();
          return (
            <>
              {canExpand ? (
                <Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
                  <Checkbox
                    checked={row.getIsSelected()}
                    indeterminate={row.getIsSomeSelected()}
                    onChange={row.getToggleSelectedHandler()}
                  />
                  <IconButton onClick={onToggle} sx={{ cursor: "pointer" }}>
                    {isExpanded ? "👇" : "👉"}
                  </IconButton>
                  <Typography>{getValue<string | undefined>()}</Typography>
                </Box>
              ) : (
                <Box
                  sx={{
                    display: "flex",
                    alignItems: "center",
                    paddingLeft: "16px",
                  }}
                >
                  <Checkbox
                    checked={row.getIsSelected()}
                    indeterminate={row.getIsSomeSelected()}
                    onChange={row.getToggleSelectedHandler()}
                  />
                  <Typography>{getValue<string>()}</Typography>
                </Box>
              )}
            </>
          );
        },
      },
      {
        accessorKey: "branchName",
        header: "支店名",
        cell: ({ getValue }) => (
          <Typography>{getValue<string | undefined>()}</Typography>
        ),
      },
      {
        accessorKey: "prefecture",
        header: "県名",
        cell: ({ getValue }) => (
          <Typography>{getValue<string | undefined>()}</Typography>
        ),
      },
      {
        accessorKey: "city",
        header: "市区町村",
        cell: ({ getValue }) => (
          <Typography>{getValue<string | undefined>()}</Typography>
        ),
      },
      {
        accessorKey: "address1",
        header: "住所1",
        cell: ({ getValue }) => {
          return <Typography>{getValue<string | undefined>()}</Typography>;
        },
      },
      {
        accessorKey: "address2",
        header: "住所2",
        cell: ({ getValue }) => (
          <Typography>{getValue<string | undefined>()}</Typography>
        ),
      },
    ],
    [],
  );
  const [displayData] = useState(buildGroups());

  const table = useReactTable({
    // memo化するなど、安定させた値を渡すこと
    data: displayData,
    columns,
    state: {
      expanded: expandedState,
    },
    onExpandedChange: setExpandedState,
    getCoreRowModel: getCoreRowModel(),
    getSubRows: (row) => row.rows && [...row.rows],
    getPaginationRowModel: getPaginationRowModel(),
    getExpandedRowModel: getExpandedRowModel(),
    // getFilteredRowModel: getFilteredRowModel(),
  });
  return (
    <Paper sx={{ width: "100%", paddingY: "40px" }}>
      <TableContainer>
        <Table>
          <TableHead>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((head) => (
                  <TableCell key={head.id} sx={{ minWidth: "100px" }}>
                    {flexRender(
                      head.column.columnDef.header,
                      head.getContext(),
                    )}
                  </TableCell>
                ))}
              </TableRow>
            ))}
          </TableHead>
          <TableBody>
            {table.getRowModel().rows.map((row) => {
              return (
                <TableRow key={row.id}>
                  {row.getVisibleCells().map((cell) => {
                    return (
                      <TableCell key={cell.id}>
                        {flexRender(
                          cell.column.columnDef.cell,
                          cell.getContext(),
                        )}
                      </TableCell>
                    );
                  })}
                </TableRow>
              );
            })}
          </TableBody>
        </Table>
      </TableContainer>
    </Paper>
  );
};

type GroupViewModel = {
  groupId: string;
} & RowDetail;
type RowDetail = {
  id: string;
  companyName?: string;
  branchName?: string;
  prefecture?: string;
  city?: string;
  address1?: string;
  address2?: string;
  rows: readonly GroupViewModel[] | undefined;
};
const buildDetail = (companyName: string): RowDetail => ({
  id: uuidv4(),
  companyName,
  branchName: undefined,
  prefecture: undefined,
  city: undefined,
  address1: undefined,
  address2: undefined,
  rows: [],
});
const group1 = uuidv4();
const group2 = uuidv4();
const group3 = uuidv4();
// dummy
const buildGroups = (): GroupViewModel[] => [
  {
    groupId: group1,
    ...buildDetail("株式会社A"),
    rows: rows.flatMap((row) =>
      row.companyName === "株式会社A"
        ? [{ ...row, groupId: group1, companyName: undefined }]
        : [],
    ),
  },
  {
    groupId: group2,
    ...buildDetail("株式会社B"),
    rows: rows.flatMap((row) =>
      row.companyName === "株式会社B"
        ? [{ ...row, groupId: group2, companyName: undefined }]
        : [],
    ),
  },
  {
    groupId: group3,
    ...buildDetail("株式会社C"),
    rows: rows.flatMap((row) =>
      row.companyName === "株式会社C"
        ? [{ ...row, groupId: group3, companyName: undefined }]
        : [],
    ),
  },
];
const rows: readonly RowDetail[] = [
  {
    id: uuidv4(),
    companyName: "株式会社A",
    branchName: "本社",
    prefecture: "東京都",
    city: "千代田区",
    address1: "千代田1-1",
    address2: "千代田ビル",
    rows: undefined,
  },
  {
    id: uuidv4(),
    companyName: "株式会社B",
    branchName: "本社",
    prefecture: "東京都",
    city: "中央区",
    address1: "日本橋1-1",
    address2: undefined,
    rows: undefined,
  },
  {
    id: uuidv4(),
    companyName: "株式会社C",
    branchName: "本社",
    prefecture: "東京都",
    city: "港区",
    address1: "新橋1-1",
    address2: undefined,
    rows: undefined,
  },
  {
    id: uuidv4(),
    companyName: "株式会社A",
    branchName: "横浜支店",
    prefecture: "神奈川県",
    city: "横浜市",
    address1: "鶴見1-1",
    address2: undefined,
    rows: undefined,
  },
  {
    id: uuidv4(),
    companyName: "株式会社B",
    branchName: "川崎支店",
    prefecture: "神奈川県",
    city: "川崎市",
    address1: "川崎1-1",
    address2: undefined,
    rows: undefined,
  },
  {
    id: uuidv4(),
    companyName: "株式会社C",
    branchName: "相模原支店",
    prefecture: "神奈川県",
    city: "相模原市",
    address1: "相模原1-1",
    address2: undefined,
    rows: undefined,
  },
];

感想

tanstack-tableの機能は他にもまだまだありますが、表現力の幅が凄まじいです。
mui以外のUIライブラリや、デザインシステムを社内で構築しているケースでも採用の余地があるのが尚良しです。
テーブルUIに複雑な要件が求められる場合に、採用してみるのはアリかと思いました。tanstackはexampleが充実しているのもありがたいですね。
routerやformもいずれ試してみたいものです

参考

https://mui.com/core/

https://tanstack.com/

余談

ちなみに、この実装をMUIのみに頼って愚直に実装する場合、プレミアムプランを契約する必要があり添付の通りの料金となります(2024年12月現在)

Discussion