Open25

TanStack Tableこと調べ

nus3nus3

Headless UI for building powerful tables & datagrids
であるTanStack Tableの使い方を調べる

https://tanstack.com/table/v8

nus3nus3

バージョン

  "dependencies": {
    "@tanstack/react-table": "8.10.6",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
nus3nus3

所管

テーブルコンポーネントを提供するというよりは、テーブルコンポーネントを作る際に管理する状態をTanStack Table側で抽象化して扱いやすいAPIを提供してくれるようなイメージ

nus3nus3

テーブルのカラムを実装する

ドキュメントにHeadless UI記載されているように、TanStack TableからDOMなどは提供されない。
テーブルコンポーネントを表示、操作するための機能を提供してくれる感じ。

nus3nus3

テーブルで扱うデータを定義

テーブルの表示に使うデータの型を定義

export type Nus3Info = {
  id: string;
  name: string;
  creator: string;
  createdAt: string;
  description: string;
};

実際使うデータを定義

const defaultData: Nus3Info[] = [
  {
    id: "1",
    name: "name-1",
    creator: "creator-1",
    createdAt: "2021-01-01",
    description: "description-1",
  },
  {
    id: "2",
    name: "name-2",
    creator: "creator-2",
    createdAt: "2021-01-01",
    description: "description-2",
  },
  // ...
];
nus3nus3

カラムを定義

TanStack Tableではカラムの種類は3つある
https://tanstack.com/table/v8/docs/guide/column-defs#column-def-types

  • Accessor Columns
    • ソート、フィルタ、グループ化ができる
  • Display Columns
    • ソートやフィルタはできないが、ボタンやチェックボックスなどを表示するのに使える
  • Grouping Columns
    • ソートやフィルタができずに他のカラムのグループ化のために使われる。ヘッダーやフッターを定義するのに一般的に使われる

これらのタイプをcreateColumnHelperというヘルパー関数を使い、定義する

const columnHelper = createColumnHelper<Nus3Info>();

const columns = [
  columnHelper.display({
    id: "edit",
    cell: (props) => (
      <button
        onClick={() => {
          console.info(props.row, "編集する行情報");
        }}
      >
        編集
      </button>
    ),
  }),
  columnHelper.accessor("id", {
    header: "ID",
    cell: (info) => info.getValue(),
  }),
  columnHelper.accessor("name", {
    header: "名前",
    cell: (info) => info.getValue(),
  }),
  //...
  columnHelper.display({
    header: "概要",
    id: "description",
    cell: (info) => (
      <span style={{ minWidth: "300px", display: "inline-block" }}>
        {info.row.original.description}
      </span>
    ),
  }),
];

nus3nus3

columnHelper.accessorの第一引数にはテーブルに表示するデータのプロパティを指定できる
headercellには引数に対象の行にアクセスできる引数を受け取りつつ、stringだけでなくDOM要素を返すこともできる

nus3nus3

hooksを使い、テーブル(オブジェクト)を生成する

Reactの場合、useReactTableに実際にテーブルに表示するデータやカラムを渡すことで、カラムとデータを紐づけ、テーブルでの表示や操作がしやすくなるAPIを提供してくれる

  const [data, setData] = useState(() => [...defaultData])
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  })

getCoreRowModel()はテーブルに表示するデータの行の部分に対して何かしらの変換処理を加える時に使う感じかな?
https://tanstack.com/table/v8/docs/api/core/table#getcorerowmodel

nus3nus3

テーブルを表示する

useReactTableで受け取ったインスタンスを使って、テーブルを表示する

      <table className={classes.table}>
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th key={header.id} className={classes.th}>
                  {header.isPlaceholder
                    ? null
                    : 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} className={classes.td}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
nus3nus3

table.getHeaderGroups()でヘッダーグループ(後述)を取得し、headerGroup.headersで各カラムのヘッダーを取得する
表示する内容が独自に定義したDOMの場合はflexRenderを使ってヘッダーの中身をレンダリングする

また、table.getRowModel()で各行の情報を取得でき、同様に行のセルの中を独自に定義したDOMを表示したい場合はflexRenderを使ってレンダリングする

nus3nus3

Column Groups

createColumnHelperを使ったカラムの定義時にネストしたカラムを定義することでGroupを定義できる
https://tanstack.com/table/v8/docs/examples/react/column-groups

const columns = [
  columnHelper.group({
    id: "hello",
    header: () => <span>Hello</span>,
    columns: [
      columnHelper.accessor("firstName", {
        cell: (info) => info.getValue(),
      }),
      columnHelper.accessor((row) => row.lastName, {
        id: "lastName",
        cell: (info) => info.getValue(),
        header: () => <span>Last Name</span>,
      }),
    ],
  }),
  columnHelper.group({
    header: "Info",
    columns: [
      columnHelper.accessor("age", {
        header: () => "Age",
      }),
      columnHelper.group({
        header: "More Info",
        columns: [
          columnHelper.accessor("visits", {
            header: () => <span>Visits</span>,
          }),
          columnHelper.accessor("status", {
            header: "Status",
          }),
          columnHelper.accessor("progress", {
            header: "Profile Progress",
          }),
        ],
      }),
    ],
  }),
];

nus3nus3

Pagination

https://tanstack.com/table/v8/docs/examples/react/pagination-controlled

useReactTableの宣言時にPaginationの情報を渡すことで、Paginationの状態を良しなに操作するAPIを提供してくれる

table.getCanPreviousPage()table.setPageIndex(0)table.nextPage()table.setPageSize()などを使うことで、ページ送りなどの操作ができ、テーブルに表示する内容(状態)も更新する

また、manualPaginationを有効にすることで、サーバーサイド側でページネーション後のデータをレスポンスで受け取り、そのデータをテーブルに表示させつつ、ページネーションの状態を更新することもできる

以下は、manualPaginationを有効にし、サーバーサイドでページネーション後のデータを取得する例

import {
  PaginationState,
  // ...
} from "@tanstack/react-table";

// ...

  const [data, setData] = useState(() => [...defaultData]);
  // TanStack TableではpageIndex, pageSizeの状態を管理してくれる
  const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
    pageIndex: 0,
    pageSize: 3, // APIから取得する想定
  });
  const pagination = useMemo(
    () => ({
      pageIndex,
      pageSize,
    }),
    [pageIndex, pageSize]
  );

  const table = useReactTable({
    data,
    columns,
    state: {
      pagination,
    },
    pageCount: 3, // apiから取得する
    onPaginationChange: setPagination,
    getCoreRowModel: getCoreRowModel(),
    manualPagination: true, // サーバーサイド側から表示するデータは受け取る
  });
nus3nus3

ページネーションの表示、操作周り

  // ...

  // TanStack Table側でpageIndexなどが変わると都度、useReactTableに渡すdataの値を更新するように
  // useEffectを使わずにonPaginationChangeの中でいい感じにできそうな気もする
  useEffect(() => {
    const handleChangePage = async () => {
      const { data, count } = await fetchPaginationData(pageIndex);
      setData(data);
      setTotalCount(count);
    };

    handleChangePage();
  }, [pageIndex, pagination]);

  // ...

  const from = useMemo(() => pageIndex * pageSize + 1, [pageIndex, pageSize]);
  const to = useMemo(() => from + pageSize - 1, [from, pageSize]);

  return (
    <div className={classes.wrapper}>
      <div className={classes.pagination}>
        {from} - {to} / {totalCount}<button
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          前へ
        </button>
        <button
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          次へ
        </button>
      </div>
   // ...

nus3nus3

Sort

https://github.com/TanStack/table/tree/main/examples/react/sorting

Paginationと同様でuseReactTable宣言時にソートの情報を渡すことで、ソートの状態を良しなに操作するAPIを提供してくれる

  • header.column.getToggleSortingHandler(): ヘッダーをクリックしたときに、そのカラムのソートの状態を切り替え
  • header.column.getIsSorted(): そのカラムがascdescか未選択(false)かを返す
  • header.column.getCanSort(): そのカラムがソートできるかどうか

またmanualSortingを有効にすることで、ソート後の表示するデータはサーバーサイドから取得する方法も選択できる

nus3nus3

サンプル

import {
  //...
  SortingState,
} from "@tanstack/react-table";

//...

  const [sorting, setSorting] = useState<SortingState>([]);

  // TanStack Table側がソートの状態を更新したら、都度、サーバーサイドから取得したレスポンスの内容を表示するように
  // useEffectを使わずにonSortingChangeの中でいい感じにできそうな気もする
  useEffect(() => {
    const handleChangeSort = async () => {
      const data = await fetchSortedData(sorting);
      setData(data);
    };

    // サーバーサイド側の時は、データを取得するまではloadingの表示をしたほうが良さそう
    handleChangeSort();
  }, [sorting]);

  const table = useReactTable({
    data,
    columns,
    state: {
      sorting,
    },
    onSortingChange: setSorting,
    getCoreRowModel: getCoreRowModel(),
    manualSorting: true,
  });
nus3nus3

表示、操作側

        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th key={header.id} className={classes.th}>
                  {header.isPlaceholder ? null : (
                    <div
                      className={
                        header.column.getCanSort() ? classes.sortBtn : undefined
                      }
                      {...{
                        onClick: header.column.getToggleSortingHandler(),
                      }}
                    >
                      {flexRender(
                        header.column.columnDef.header,
                        header.getContext()
                      )}
                      {{
                        asc: " 🔼",
                        desc: " 🔽",
                      }[header.column.getIsSorted() as string] ?? null}
                    </div>
                  )}
                </th>
              ))}
            </tr>
          ))}
        </thead>

nus3nus3
export interface ColumnSort {
  desc: boolean;
  id: string;
}
export type SortingState = ColumnSort[];
nus3nus3

ざっくりまとめ

  • TanStack Tableが提供するヘルパー関数を使って、カラムを定義する
  • テーブルに表示するデータとカラムはプロパティ名とaccessorを同一にすることで紐づく
  • useReactTableにデータとカラムを渡すことで、テーブルを操作、表示するのにいい感じに抽象化された機能を使えるようになる
  • ソートもページネーションもクライアントサイドで完結するものだけでなく、サーバーサイド込のオプションも用意されている
  • Headless UI(厳密にはUIではなく、テーブルを扱いやすくするオブジェクトを提供してくれるのでHeadless UIかはさておき)DOMを提供してくれてるわけではないので、テーブルコンポーネントの見た目は利用者側が実装する
nus3nus3

TanStack Tableを内部で利用するテーブルコンポーネントの定義を考える

サンプル実装した対象のプルリク
https://github.com/nus3/tanstack-table-demo/pull/2

nus3nus3

useReactTableでGitHubを全文検索したら、TanStack Tableが提供するAPIをre-exportしてる実装をしてるリポジトリを発見した
https://github.com/oxidecomputer/console

この実装であれば、テーブルのUI自体はUIライブラリ側(自分たち側)で定義でき、TanStack Tableの自由なカラム定義などはそのまま利用できるのでヨサソウ

nus3nus3

カラムを自由に定義できるヘルパー関数とtableオブジェクトの生成、またPaginationとSortの際の状態をre-export

src/functions/table.ts
import {
  RowData,
  TableOptions,
  getCoreRowModel,
  useReactTable as useReactTableOrig,
  createColumnHelper,
  PaginationState,
  SortingState,
} from "@tanstack/react-table";

export const useReactTable = <T extends RowData>(
  options: Omit<TableOptions<T>, "getCoreRowModel">
) => useReactTableOrig({ ...options, getCoreRowModel: getCoreRowModel() });

export { createColumnHelper };

export type { PaginationState, SortingState };
nus3nus3

Tableコンポーネントではtableオブジェクトとページネーションに必要な値をpropsで受け取り、コンポーネントのPresentationの部分を実装する

src/components/Table.tsx
import { Table as TableInstance, flexRender } from "@tanstack/react-table";

type TableProps<T> = {
  table: TableInstance<T>;
  totalCount: number; // paginationの表示に必要な値
  pageIndex: number; // paginationの表示に必要な値
  pageSize: number; // paginationの表示に必要な値
};

export const Table = <T extends object>({
  table,
  totalCount,
  pageIndex,
  pageSize,
}: TableProps<T>) => {
  // table要素などのマークアップ
}
nus3nus3

Tableコンポーネントの利用側でre-exportされたヘルパーやtableオブジェクトの生成を行う

import { Table } from "./components/Table";
import {
  createColumnHelper,
  useReactTable,
  PaginationState,
  SortingState,
} from "./functions/table";


const columnHelper = createColumnHelper<Nus3Info>();
const columns = [
  columnHelper.display({
    id: "edit",
    cell: (props) => (
      <button
        onClick={() => {
          console.info(props.row, "編集する行情報");
        }}
      >
        編集
      </button>
    ),
  }),
  columnHelper.accessor("id", {
    header: "ID",
    cell: (info) => info.getValue(),
  }),
//... 残りのカラムの定義
];



function App() {
  // ...ソートやページネーションのイベントハンドラを定義

  const table = useReactTable({
    data,
    columns,
    state: {
      sorting,
      pagination,
    },
    pageCount: 3, // apiから取得する
    onPaginationChange: setPagination,
    onSortingChange: setSorting,
    debugTable: true,
    manualSorting: true,
    manualPagination: true,
    enableMultiSort: false,
  });

  return (
    <>
      <h1>TanStack Table Demo</h1>
      <Table
        table={table}
        pageIndex={pageIndex}
        pageSize={pageSize}
        totalCount={totalCount}
      />
    </>
  );

}
nus3nus3

サンプル実装だとSortとPaginationの状態の変更があった際にuseEffectの中で再fetchするようになってるが、useReactTableで渡すonPaginationChangeやonSortingChangeをTableコンポーネントのpropsとして設定できるようにすると、useEffectの中で再fetchしなくてもいいようにできそう