FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

ヘッドレスUIでいこう!(TanStack Table編)

こんにちは、ブログリレー31日目を担当します、石橋です。 現在私が参加しているWebアプリケーションでは、広告レポート等を表示するテーブルにMUI X Data GridのPRO(課金版)を使っています。

MUI X Data Gridは高性能で使い勝手もいいライブラリなのですが、一方で不満もないわけではなく、最近は同等の機能を持つヘッドレスUIコンポーネントがあれば移行したいな、という気持ちがチーム内にあります。 そこで今回は、こちらも高機能かつヘッドレスUIのテーブルライブラリTanStack Tableを素振りしてみたいと思います。

前置き、MUI X Data Grid PROについて

そもそもMUI X Data Grid PROを使うことになった経緯は、はっきり言ってなし崩しです。

  1. 機能仕様はあれど詳細なUIデザインは白紙の状態からアプリケーションを作るので、知見のあるMUIを採用
  2. 高機能テーブルが必要だ。MUI X Data Gridを使おう
  3. 沢山要望があるけどMUI X Data Grid PRO(課金版)なら簡単に実現できるから使おう
  4. UIデザインが届いた!MUIのスタイルをいちいち塗りつぶすのは辛いなあ
  5. そうだヘッドレスUIで行こう!(現在)

高機能なMUI X Data Gridのおかげでテーブルに関しては多様な要望にも応えることができました。 MUI X Data GridはapiRef経由でテーブルを操作できるので、標準にはないツールなどの、機能実装の自由度は非常に高いです。

一方でUIデザインが揃う過程で、そもそもMUIとUIデザインの乖離を管理するのが辛い、脱MUIを図りたい、という状況になってきました。 MUIのスタイルを完全に塗りつぶしつつ、MUIの文脈でスタイルやテーマを管理するのは試行錯誤が求められます。ライブラリアップデートも機能とUIの両面を維持しなければならないので、気を使うこと大です。 これはそのままMUI X Data Gridにも当てはまります。

まあ辛いよね。という雰囲気がチーム内にあり、ヘッドレスUI移行計画が水面下で進行中です。

加えてMUI X Data Grid PROは有料ライブラリなので、そこから脱却したい、というモチベーションもあります。継続的にアップデートを利用したい場合は実質サブスクリプション契約が必要になります。

TanStack Tableについて

TanStack Table(v8)

Headless UI for building powerful tables & datagrids Supercharge your tables or build a datagrid from scratch for TS/JS, React, Vue, Solid & Svelte while retaining 100% control over markup and styles.

Reactに限らず様々な条件で使える高機能なヘッドレスUIテーブルライブラリです。 TanStack Tableをバックエンドに使ってデザインを当て込んだライブラリもいくつかありました。デモをいくつか触ってみると、機能的にはMUI X Data Gridと遜色なさそうです。

参考になる実装が多いとそれだけで敷居が下がるので、その点でもよさそうです!

素振り

というわけで公式の実装例やMaterial React Tableを参考に素振りしてみます。MUI X Data Gridの課金必須機能を重点的に試してみました。

  1. 基本的な使い方を知る
  2. カラムサイズをドラッグ&ドロップで拡大縮小する(MUI X Data Gridでは要課金)
  3. カラムの順序をドラッグ&ドロップで入れ替える(MUI X Data Gridでは要課金)
  4. カラムをテーブル右端/左端に固定する(MUI X Data Gridでは要課金)
  5. pagination

ちなみに完成したのはこちらです。カラムヘッダの右端に拡大縮小のつまみ、カラムヘッダ自体をDnDで入れ替え(codesandbox埋め込みだと動かないかもしれません!)、カラムヘッダの<<>>ボタンで左右固定が行なえます。

基本的な使い方

簡単なデータを用意してテーブルを描いてみます。

type Data = {
  id: string;
  enumValue: Enum;
  nullableRatio?: number;
  valueWithMemo: ValueWithMemo;
};

続いて、Tableコンポーネント作っていきます。まずuseReactTabletableオブジェクトを作成します。引数に渡すdataは表示したいData型配列、columnsはカラム定義の配列、getCoreRowModelはおまじないのようです。getPaginationRowModelなども利用でき、使いたい機能があれば必要に応じて追加設定するようです。

export function Table() {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    debugTable: true,
    debugHeaders: true,
    debugColumns: true,
  });
  ...

カラム定義はこのようにしてみました。headerにフィールド名、cellにレンダリングロジックを記述します。columnHelper経由で実装すると型推論が効くようです。

const columnHelper = createColumnHelper<Data>();
const columns = [
  columnHelper.accessor("id", {
    header: "id",
    cell: (props) => props.getValue(),
  }),
  columnHelper.accessor("enumValue", {
    header: "enumValue",
    cell: (props) => props.getValue(),
  }),
  columnHelper.accessor("nullableRatio", {
    header: "nullableRatio",
    cell: (props) => {
      const value = props.getValue();
      return value !== undefined ? ratioFormatter.format(value) : "-";
    },
  }),
  columnHelper.accessor("valueWithMemo", {
    header: "valueWithMemo",
    cell: (props) => props.getValue().value ?? props.getValue().memo,
  }),
];

Tableコンポーネント内で具体的にテーブルを描画します。table APIからヘッダ、カラム、セルなどの情報に手軽にアクセスできます。 flexRenderはおそらくカラム定義に基づいてセルをレンダリングしてくれる標準のレンダラです。必要なら独自のレンダラに置き換えることもできる、ということだと思います。 今回はカラムヘッダにツールボタンをどんどん増やすことにしたので、それをレンダラに切り出すような用途がありそうです。

export function Table() {
  const table = useReactTable({
    (中略)
  })
  return (
    <>
      <table>
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th key={header.id}>
                  {flexRender(
                    header.column.columnDef.header,
                    header.getContext(),
                  )}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {table.getRowModel().rows.map((row) => (
            <tr key={row.id}>
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </>
  );
}

「なるほどこれがヘッドレスUIなのね」というのが分かっていただけるのではないでしょうか。テーブルの状態や機能はtableオブジェクトに集約されており、開発者はUIに注力することができます。

カラムサイズをドラッグ&ドロップで拡大縮小する

つづいて細かい機能を作ってみます。

useReactTablecolumnResizeModeを追加します。"onChange"を使うとドラッグ中にインタラクティブにサイズが変わります。 ヘッダにリサイズのためのつまみを追加してheaderから生えているイベントハンドラを設定して完成です。 イベントハンドラ(getResizeHandler)は予め用意されているので使うだけという楽ちん仕様です!

export function Table() {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    columnResizeMode: "onChange", // NEW!
    debugTable: true,
    debugHeaders: true,
    debugColumns: true,
  });
  return (
    <>
      <div className="table" style={{ width: table.getCenterTotalSize() }}>
        <div className="thead">
          {table.getHeaderGroups().map((headerGroup) => (
            <div className="tr" key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <div
                  key={header.id}
                  className="th"
                  style={{
                    width: header.getSize(), // カラム幅を反映
                  }}
                >
                  {flexRender(
                    header.column.columnDef.header,
                    header.getContext(),
                  )}
                  <div
                    {...{
                      onMouseDown: header.getResizeHandler(), // イベントハンドラを設定
                      onTouchStart: header.getResizeHandler(), // イベントハンドラを設定
                      className: `resizer ${
                        header.column.getIsResizing() ? "is-resizing" : ""
                      }`,
                    }}
                  />
                </div>
              ))}
            </div>
          ))}
        </div>
        ...(以下略)

カラムの順序をドラッグ&ドロップで入れ替える

カラム順序stateをuseReactTableに渡す必要がありました。useReactTableは必要な機能によってはstateを要求するのですが、どういうときに必要なのかははっきり理解できていません。 ただしstateの渡し方は簡潔です。

export function Table() {
  const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>(
    columns.map((column) => column.header),
  );
  const table = useReactTable({
    data,
    columns,
    state: {
      columnOrder, // NEW!
    },
    onColumnOrderChange: setColumnOrder, // NEW!
    getCoreRowModel: getCoreRowModel(),
    columnResizeMode: "onChange",
    debugTable: true,
    debugHeaders: true,
    debugColumns: true,
  });

ヘッダのドラッグ&ドロップは自前で実装が必要なので、公式の実装例を参考にDraggableThを作成しました。 ここではReact DnDを使っています。 これをthの代わりに使えば完成です。

function DraggableTh({
  header,
  table,
  children,
}: {
  header: Header<Data, unknown>;
  table: Table<Data>;
  children: React.ReactNode;
}) {
  const { getState, setColumnOrder } = table;
  const { columnOrder } = getState();
  const { column } = header;

  const [, dropRef] = useDrop({
    accept: "column", // unique key in app
    drop: (draggedColumn: Column<Data>) => {
      const newColumnOrder = reorderColumn(
        draggedColumn.id,
        column.id,
        columnOrder,
      );
      setColumnOrder(newColumnOrder);
    },
  });

  const [{ isDragging }, dragRef] = useDrag({
    collect: (monitor) => ({
      isDragging: monitor.isDragging(),
    }),
    item: () => column,
    type: "column",
  });

  function attachRef(el: ConnectableElement) {
    dragRef(el);
    dropRef(el);
  }

  return (
    <div
      className="th"
      ref={attachRef}
      style={{ opacity: isDragging ? 0.5 : 1, width: header.getSize() }}
      draggable={!table.getState().columnSizingInfo.isResizingColumn}
    >
      {children}
    </div>
  );
}

function reorderColumn(
  draggedColumnId: string,
  targetColumnId: string,
  columnOrder: string[],
): ColumnOrderState {
  columnOrder.splice(
    columnOrder.indexOf(targetColumnId),
    0,
    columnOrder.splice(columnOrder.indexOf(draggedColumnId), 1)[0] as string,
  );
  return [...columnOrder];
}

カラムをテーブル右端/左端に固定する

まず固定するカラムのstateをuseReactTableに追加します。試しに初期状態でidを固定してみましょう。 また、カラムを固定するためのボタンをヘッダに追加します。例によってイベントハンドラはheader.columnに生えています。

export function Table() {
  const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>(
    columns.map((column) => column.header as string),
  );
  const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({
    left: ["id"],
  });
  const table = useReactTable({
    data,
    columns,
    state: {
      columnOrder,
      columnPinning, // NEW!
    },
    onColumnOrderChange: setColumnOrder,
    onColumnPinningChange: setColumnPinning, // NEW!
    getCoreRowModel: getCoreRowModel(),
    columnResizeMode: "onChange",
    debugTable: true,
    debugHeaders: true,
    debugColumns: true,
  });

  return (
    <>
      <div className="table">
        <div className="thead">
          {table.getHeaderGroups().map((headerGroup) => (
            <div className="tr" key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <DraggableTh key={header.id} header={header} table={table}>
                  {flexRender(
                    header.column.columnDef.header,
                    header.getContext(),
                  )}
                  <button // NEW!
                    className="pin"
                    onClick={() =>
                      header.column.pin(
                        header.column.getIsPinned() === "left" ? false : "left",
                      )
                    }
                  >
                    {header.column.getIsPinned() === "left" ? "x" : "<<"}
                  </button>
                  <button // NEW!
                    className="pin"
                    onClick={() =>
                      header.column.pin(
                        header.column.getIsPinned() === "right"
                          ? false
                          : "right",
                      )
                    }
                  >
                    {header.column.getIsPinned() === "right" ? "x" : ">>"}
                  </button>

ただしこれだけだとカラムを左、中央、右の各ブロックに振り分けるだけなので、UI上でのカラム固定は自前で実装が必要です。 ここはお手軽に、カラムヘッダのスタイルにposition: stickyleftrightを設定します。

function DraggableTh({
  header,
  table,
  children,
}: {
  header: Header<Data, unknown>;
  table: Table<Data>;
  children: React.ReactNode;
}) {
  (中略)
  return (
    <div
      className={`th ${header.column.getIsPinned() && "pinned"}`}
      ref={header.column.getIsPinned() ? null : attachRef}
      style={{
        opacity: isDragging ? 0.5 : 1,
        width: header.getSize(),
        left: // NEW!
          column.getIsPinned() === "left"
            ? `${column.getStart("left")}px`
            : undefined,
        right: // NEW!
          column.getIsPinned() === "right"
            ? `${getTotalRight<Data>(table, column)}px`
            : undefined,
      }}
      draggable={!table.getState().columnSizingInfo.isResizingColumn}
    >
      {children}
    </div>
  );
}

右固定カラムのrightは以下のように計算できます。

function getTotalRight(
  table: Table<Data>,
  column: Column<Data, unknown>,
) {
  return table
    .getRightLeafHeaders()
    .slice(column.getPinnedIndex() + 1)
    .reduce((acc, col) => acc + col.getSize(), 0);
}

pagination

useReactTablegetPaginationRowModelを追加します。

  const table = useReactTable({
    data,
    columns,
    state: {
      columnOrder,
      columnPinning,
    },
    onColumnOrderChange: setColumnOrder,
    onColumnPinningChange: setColumnPinning,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(), // NEW!
    columnResizeMode: "onChange",
    debugTable: true,
    debugHeaders: true,
    debugColumns: true,
  });

あとは無心でtableのAPIを叩くpaginationを作って、テーブルの下に配置するだけです。簡単ですね。

<div className="pagination">
  <button
    onClick={() => table.setPageIndex(0)}
    disabled={!table.getCanPreviousPage()}
  >
    {"<<"}
  </button>
  <button
    onClick={() => table.previousPage()}
    disabled={!table.getCanPreviousPage()}
  >
    {"<"}
  </button>
  <button
    onClick={() => table.nextPage()}
    disabled={!table.getCanNextPage()}
  >
    {">"}
  </button>
  <button
    onClick={() => table.setPageIndex(table.getPageCount() - 1)}
    disabled={!table.getCanNextPage()}
  >
    {">>"}
  </button>
  | Page
  {`${
    table.getState().pagination.pageIndex + 1
  } of ${table.getPageCount()}`}
  | {table.getRowModel().rows.length} Rows
  <select
    value={table.getState().pagination.pageSize}
    onChange={(e) => {
      table.setPageSize(Number(e.target.value));
    }}
  >
    {[2, 5, 10].map((pageSize) => (
      <option key={pageSize} value={pageSize}>
        Show {pageSize}
      </option>
    ))}
  </select>
</div>

まとめ

TanStack Tableを使って面倒くさそうな機能を実装してみました。 そもそも高機能である上にAPIが分かりやすく、各種イベントハンドラまで提供されており、至れり尽くせりという印象です。

stateの型定義やuseReactTableを使ったstate初期値の渡し方も明快で、例えばテーブルの状態を保存/復元するようなユースケースも簡単に実装できそうです。 MUI X Data Gridのstate周りは難解なので、わかりやすさでもTanStack Tableに優位性がありそうです。 いいですね。

また、MUI X Data Gridから移行する際に注意が必要な機能的な差異としては、filteringの思想MUI X Data Gridの構造化されたfilteringと は異なるのでUI/UXが維持しづらそう、という懸念があります。 あと、CSV等のExport機能は自作が必要です。

以上、TanStack Tableを触ってみました。 みなさんの健やかなテーブルUIライフ、ヘッドレスUIライフの一助になれば幸いです!