🧩

実装例から見る Tanstack Table の使い方

2024/08/17に公開

はじめに

弊社では商業施設向けの Vertical SaaS を開発・運営しているのですが、日々大量のデータを扱う SaaS において

  • ユーザーにとって見やすい形にデータを整形できる
  • 複雑なデータ同士を照らし合わせて分析、比較を行える
  • ユーザーがデータを自由に操作(フィルター・ソートなど)し、そこから次のアクションに移すためのアイデアが得られる

ことは何よりも重要であり、それらを実現するために優れたデザインのテーブルは必要不可欠です。

そこで今回は、ヘッドレス UI ライブラリである「Tanstack Table」を用いてそのようなデータテーブルの設計パターンを実装法とあわせてご紹介しようと思います。

Tanstack Table の独特な使い方がなかなか理解できず、私自身非常に苦労したため、同じような悩みを抱えている方にとって参考になれば嬉しいです。

ぜひ最後までご覧ください。

前提

本記事で使用する主要なライブラリのバージョンは次のとおりです。

  "dependencies": {
    "@dnd-kit/core": "^6.1.0",
    "@dnd-kit/modifiers": "^7.0.0",
    "@dnd-kit/sortable": "^8.0.0",
    "@dnd-kit/utilities": "^3.2.2",
    "@tanstack/react-table": "^8.20.1",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },

また、本記事で使用したコード群については下記リポジトリでまとめて公開しています。

https://github.com/youliangdao/tanstack-table-design-pattern

最終的なディレクトリ構造は下記のようになっております。
Table に関するコンポーネントは基本的にはsrc/personsの中に含まれています。

├── src
│   ├── App.tsx
│   ├── components
│   │   └── ui
│   │       ├── button.tsx
│   │       ├── dropdown-menu.tsx
│   │       ├── input.tsx
│   │       ├── select.tsx
│   │       └── table.tsx
│   ├── lib
│   │   ├── getCommonPinningStyle.ts
│   │   └── utils.ts
│   ├── main.tsx
│   ├── persons
│   │   ├── columns.tsx
│   │   ├── data-table-column-footer.tsx
│   │   ├── data-table-column-header.tsx
│   │   ├── data-table-editable-cell.tsx
│   │   ├── data-table-remove-row.tsx
│   │   ├── data-table.tsx
│   │   └── index.tsx
│   ...
...

Tanstack Table について

まず Tanstack Table について簡単に説明しておきます。

TanStack Table is a Headless UI library for building powerful tables & datagrids for TS/JS, React, Vue, Solid, Qwik, and Svelte.

Tanstack Table は React に限らずさまざまな条件で使える高機能なヘッドレス UI テーブルライブラリです。

ヘッドレス UI についての説明[1]は他に譲るとして、React 以外の FW やライブラリでも使えるのは非常に魅力的ですね。
マイナーどころでいうと Svelte や Lit などでも使用可能です。

https://medium.com/@morkadosh/build-beautiful-accessible-tables-that-work-everywhere-with-lit-tanstack-table-and-twind-1275049d53a1

また Tanstack Table をベースにデザインを当て込んだ UI ライブラリもいくつか存在しており、開発者によってコンポーネントベースとヘッドレス UI ベースを使い分けられるようになっています。

https://www.material-react-table.com/

https://www.mantine-react-table.com/

基本的な使い方

次に Tanstack Table の基本的な使い方を見ていきましょう。

まずは基本的なテーブルを作ることからはじめていきます。

下記はデータを表示するだけのベーシックなテーブルです。
(テーブルのスタイリングには shadcn/ui の Table コンポーネントを使用しています)

https://github.com/youliangdao/tanstack-table-design-pattern/tree/main/basic-table/src

Tanstack Table を用いたテーブルの実装の流れは、次の 4 ステップに分かれます。

  1. テーブルデータの型を定義
  2. カラム定義を作成
  3. useReactTableを用いてテーブルの情報を取得
  4. レンダリング

1. テーブルデータの型を定義

まずは表示するデータを定義します。

ここではわかりやすいように次のような型のデータを扱うことにします。

1.テーブルデータの型を定義
type Person = {
  id: string;
  firstName: string;
  lastName: string;
  age: number;
  category: "バックエンド" | "フロントエンド" | "インフラ" | "その他";
  skills: string;
  status: "ToDo" | "In Progress" | "Done";
};

export const persons: Person[] = [
  {
    id: "1",
    firstName: "React",
    lastName: "太郎",
    age: 25,
    category: "フロントエンド",
    skills: "React",
    status: "Done",
  },
  {
    id: "2",
    firstName: "Vue",
    lastName: "花子",
    age: 30,
    category: "フロントエンド",
    skills: "Vue",
    status: "In Progress",
  },
  {
    id: "3",
    firstName: "Node",
    lastName: "次郎",
    age: 35,
    category: "バックエンド",
    skills: "Node",
    status: "ToDo",
  },
];

注意点としては、当然ですが配列データの各オブジェクトは(後の)テーブルの行になるということです。

2. カラム定義を作成

次にテーブルを構築する上でもっとも重要な「カラム定義」を作成します。

このカラム定義の役割としては下記のとおりです。

  • 表示されるデータがどのようにフォーマットされ、ソートされ、フィルタリングされるのかの定義
  • ヘッダーやフッターの作成

要するに 1 で定義したデータをテーブル用に変換する、といったイメージですね。

2. カラム定義を作成
import { ColumnDef } from "@tanstack/react-table"

export type Person = {
  id: string;
  firstName: string;
  lastName: string;
  age: number;
  category: "バックエンド" | "フロントエンド" | "インフラ" | "その他";
  skills: string;
  status: "ToDo" | "In Progress" | "Done";
};

// カラム定義
export const columns: ColumnDef<Person>[] = [
  {
    accessorKey: "id",
    header: "ID",
  },
  {
    accessorKey: "firstName",
    header: "性",
  },
  {
    accessorKey: "lastName",
    header: "名",
  },
  {
    accessorKey: "age",
    header: "年齢",
  },
  {
    accessorKey: "category",
    header: "カテゴリ",
  },
  {
    accessorKey: "skills",
    header: "スキル",
  },
  {
    accessorKey: "status",
    header: "ステータス",
  },
];

カラム定義は、ColumnDef 型で定義できます。そして、列の値を抽出するために行データのキーとして、accesorKeyが必要になります。

このaccessorKey ですが、データのプロパティの名前と一致する必要があり、一意の値を設定する必要があります。
(同じ accssorKey を設定するとブラウザのコンソール上にエラーが表示されてしまいます)

さらにカラム定義ではセルやヘッダー、フッターのフォーマットを自由に指定することも可能です。
今回はヘッダーにカスタムコンテンツを表示していますが、フッターやセルを自由にカスタマイズすることも可能です。

https://tanstack.com/table/latest/docs/guide/column-defs

3. useReactTableを用いてテーブルの情報を取得

次にuseReactTable に 1 で定義したデータと 2 で定義したカラムを渡し、テーブルの情報を取得します。

ここで React 側と接続しているイメージですね。

このフックの目的は "React" 方式 で状態を管理し、型を提供し、セル/ヘッダー/フッターのレンダリングを実装することになります。

3. useReactTableを用いてテーブルの情報を取得
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

ここでgetCoreRowModelというオプションも設定しないとエラーになってしまうので注意が必要です。

このオプションは説明するのが少し難しいのですが、行データをどのように管理するのか?を示したモデルになります。

今回は、getCoreRowModelというもっともベーシックなモデルを指定しています。これはテーブルに渡された元のデータを 1:1 にマッピングしただけの基本的な行モデルを返します。

getCoreRowModelの他にもいくつか行モデルは存在しています。
ここでは代表的なものだけ挙げておきます。(後述の具体的な実装例で使用します)

行モデル 説明
getFilteredRowModel フィルタリングを考慮した行モデルを返します。
getSortedRowModel ソートが適用された行モデルを返します。
getExpandedRowModel 展開/非表示のサブ行を考慮した行モデルを返します。
getPaginationRowModel ページネーションの状態に基づいて現在のページに表示されるべき行のみを含む行モデルを返します。

4. レンダリング

最後に 3 で取得したテーブルの情報を元にレンダリングします。

4. レンダリング
import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table";

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
}

export function DataTable<TData, TValue>({
  // 1と2で定義したdataとcolumns
  columns,
  data,
}: DataTableProps<TData, TValue>) {
  // 3で定義したテーブル情報
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <div className="rounded-md border">
      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id}>
              {headerGroup.headers.map((header) => {
                return (
                  <TableHead key={header.id}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext(),
                        )}
                  </TableHead>
                );
              })}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {table.getRowModel().rows?.length ? (
            table.getRowModel().rows.map((row) => (
              <TableRow
                key={row.id}
                data-state={row.getIsSelected() && "selected"}
              >
                {row.getVisibleCells().map((cell) => (
                  <TableCell key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow>
              <TableCell colSpan={columns.length} className="h-24 text-center">
                No results.
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </div>
  );
}
  • getHeaderGroup
    • テーブルのヘッダー情報を返す
    • 返されるヘッダー情報の headers プロパティには header オブジェクトが配列で含まれている
    • この配列を map 関数で展開したheaderオブジェクトを flexRender 関数を利用してヘッダーを表示している
  • getRowModel
    • テーブルの行情報を返す
    • 返されるテーブルの行情報にはプロパティとして配列 rows が含まれている。
  • getVisibleCells
    • 上記の配列rowsを map 関数で展開したrowオブジェクトが持つ
    • テーブルのセル情報を返す
    • 返されるセル情報にはcell が配列で含まれている。その配列を map 関数で展開した cell オブジェクトを flexRender 関数を利用してセル(行情報)を表示させている

基本的には、flexRender というヘルパー関数に 行が持つ情報を渡して、columns で定義したコンポーネントがそれぞれの情報をレンダリングするといった仕組みです。

詳細はドキュメントなどを参考にしてください。

https://tanstack.com/table/latest/docs/api/core/table

よくあるユースケースを Tanstack Table で実装してみる

ここからは上記のベーシックなテーブルを元に、実際によく使われるテーブルを実装例とともに紹介していきたいと思います。

Tanstack Table の使い方を学ぶにはサンプルコードを見るのが 1 番効果的だと思いますので、ぜひコードを見たりいじったりしながら参考にしてください。

1. カラムの操作関連(+ページネーション)

まずは使用頻度の高いカラム操作が絡む例を見ていきましょう。

ソート

最初はカラムのソートです。
ユーザーにとってデータの並び順を自由に操作したいというのはよくあるユースケースです。

以下がサンプルと実装例です。

Image from Gyazo

https://github.com/youliangdao/tanstack-table-design-pattern/tree/main/sort-table/src

重要な点としては、useReactTable の引数に getSortedRowModel()(=行モデル)を渡すことで、クライアント側でのソート機能を実現できるということです。

またソートの状態は SortingState で表し、配列の先頭の要素から順に値をキーとして持つことでソートが行われます。

data-table.tsx
  import {
    ColumnDef,
    SortingState,
    flexRender,
    getCoreRowModel,
+   getSortedRowModel,
    useReactTable,
  } from "@tanstack/react-table"

  export function DataTable<TData, TValue>({
    columns,
    data,
  }: DataTableProps<TData, TValue>) {
+   const [sorting, setSorting] = useState<SortingState>([])

    const table = useReactTable({
      data,
      columns,
      getCoreRowModel: getCoreRowModel(),
+     onSortingChange: setSorting,
+     getSortedRowModel: getSortedRowModel(),
+     state: {
+       sorting,
+     },
    })

    return (
      <div>
        <div className="rounded-md border">
          <Table>{ ... }</Table>
        </div>
      </div>
    )
  }

その他重要なものに関しては下記にまとめています。

SortingState について

ソートの State は、以下の形状を持つオブジェクトの配列として定義されています。

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

実際にソートの状態にアクセスすることはそこまでないと思いますが、クエリパラメーターにソート情報を保持することなどを求められた際には必要になってきますので、あわせて知っておくとよいでしょう。

今回はクエリパラメーターのサンプルは割愛いたしますが、気になる方は公式ドキュメントにサンプルコードがありますので、ぜひそちらを参考にしてください。

React Example: Query Router Search Params

onSortingChange について

onSortingChangeSortingStateが変化したときに内部的に呼び出されるものです。

今回はsortingの状態をテーブルの外部で管理しているため、setSortingを設定しています。

toggleSorting について
toggleSorting: (desc?: boolean, isMulti?: boolean) => void

toggleSortingは Column の API でカラムのソート状態を切り替えるものです。
desc が指定されている場合、ソートの方向を降順に強制します。

https://tanstack.com/table/latest/docs/guide/column-sizing

フィルター

次はフィルターです。
フィルターもソートと同様に使用頻度の高いカラム操作です。

以下がサンプルとその実装例です。

Image from Gyazo

https://github.com/youliangdao/tanstack-table-design-pattern/tree/main/filter-table/src

ソートと同様に、getFilteredRowModel()という行モデルを指定して、さらにテーブル外(columnfilters)でカラムフィルターの状態を制御しています。

data-table.tsx
  import {
    ColumnDef,
    flexRender,
    getCoreRowModel,
    getSortedRowModel,
+   getFilteredRowModel,
    useReactTable,
    SortingState,
+   ColumnFiltersState,
  } from "@tanstack/react-table";

  interface DataTableProps<TData, TValue> {
    columns: ColumnDef<TData, TValue>[];
    data: TData[];
  }

  export function DataTable<TData, TValue>({
    columns,
    data,
  }: DataTableProps<TData, TValue>) {
    const [sorting, setSorting] = useState<SortingState>([]);
+   const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
    const table = useReactTable({
      data,
      columns,
      getCoreRowModel: getCoreRowModel(),
      onSortingChange: setSorting,
+     onColumnFiltersChange: setColumnFilters,
+     getFilteredRowModel: getFilteredRowModel(),
      getSortedRowModel: getSortedRowModel(),
      state: {
        sorting,
+       columnFilters,
      },
    });

    return (
      <div>
+        <div className="flex items-center py-4">
+         <Input
+           placeholder="Filter Skills..."
+           value={(table.getColumn("skills")?.getFilterValue() as string) ?? ""}
+           onChange={(event) =>
+             table.getColumn("skills")?.setFilterValue(event.target.value)
+           }
+           className="max-w-sm"
+         />
+       </div>
        <div className="rounded-md border">
          <Table>{ ... }</Table>
        </div>
      </div>
    );
  }

ひとつ注意点としては、今回のサンプルでは フィルタリングは skills カラムにしか有効にしていないということです。他のカラムでも同様のフィルタリングをカスタマイズできますが、ここでは割愛いたします(詳細はドキュメント等を参照してください)

https://tanstack.com/table/latest/docs/api/features/column-filtering

その他重要なものについては下記にまとめています。

getFilterValue と setFilterValue について

どちらも Column の API であり、column オブジェクトが持っている関数です。

column.getFilterValueは入力のデフォルトの初期フィルター値を取得したりするのに利用されます。

またcolumn.setFilterValueは onChange または onBlur ハンドラーにフィルター入力を接続するのによく用いられ、フィルタリングする値を設定できます。

ページネーション

次はページネーションです。
テーブルの行数が多くなる場合に、複数のページに分けてページネーションを配置することはよくあるケースのひとつです。

下記がサンプルとその実装例になります。

Image from Gyazo

https://github.com/youliangdao/tanstack-table-design-pattern/tree/main/pagination-table/src

ここも、getPaginationRowModelという行モデルを指定しています。

ただしページネーションに関しては、組み込みの state と API で管理可能なので、とくに初期値などを別 state として定義していません。(ソートやフィルターのように useState を用いて定義していない)

data-tables.tsx
  import {
    ColumnDef,
    ColumnFiltersState,
    flexRender,
    getCoreRowModel,
    getFilteredRowModel,
+   getPaginationRowModel,
    getSortedRowModel,
    SortingState,
    useReactTable,
  } from "@tanstack/react-table";

  interface DataTableProps<TData, TValue> {
    columns: ColumnDef<TData, TValue>[];
    data: TData[];
  }

  export function DataTable<TData, TValue>({
    columns,
    data,
  }: DataTableProps<TData, TValue>) {
    const [sorting, setSorting] = useState<SortingState>([]);
    const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
    const table = useReactTable({
      data,
      columns,
      getCoreRowModel: getCoreRowModel(),
      onSortingChange: setSorting,
      onColumnFiltersChange: setColumnFilters,
      getFilteredRowModel: getFilteredRowModel(),
      getSortedRowModel: getSortedRowModel(),
+     getPaginationRowModel: getPaginationRowModel(),
      state: {
        sorting,
        columnFilters,
      },
    });

    return (
      <div className="space-y-4">
        ...
+       <div className="flex items-center justify-between px-2">
+         ...
+       </div>
      </div>
    );
  }

他に説明が必要な部分としては、ページネーションの UI 部分でしょうか。

return (
  <div className="space-y-4">
    ...
    <div className="flex items-center justify-between px-2">
      <div className="flex-1 text-sm text-muted-foreground">
        {table.getFilteredSelectedRowModel().rows.length} of{" "}
        {table.getFilteredRowModel().rows.length} row(s) selected.
      </div>
      <div className="flex items-center space-x-6 lg:space-x-8">
        <div className="flex items-center space-x-2">
          <p className="text-sm font-medium">Rows per page</p>
          <Select
            value={`${table.getState().pagination.pageSize}`}
            onValueChange={(value) => {
              table.setPageSize(Number(value));
            }}
          >
            <SelectTrigger className="h-8 w-[70px]">
              <SelectValue placeholder={table.getState().pagination.pageSize} />
            </SelectTrigger>
            <SelectContent side="top">
              {[10, 20, 30, 40, 50].map((pageSize) => (
                <SelectItem key={pageSize} value={`${pageSize}`}>
                  {pageSize}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
        <div className="flex w-[100px] items-center justify-center text-sm font-medium">
          Page {table.getState().pagination.pageIndex + 1} of{" "}
          {table.getPageCount()}
        </div>
        <div className="flex items-center space-x-2">
          <Button
            variant="outline"
            className="hidden h-8 w-8 p-0 lg:flex"
            onClick={() => table.setPageIndex(0)}
            disabled={!table.getCanPreviousPage()}
          >
            <span className="sr-only">Go to first page</span>
            <DoubleArrowLeftIcon className="h-4 w-4" />
          </Button>
          <Button
            variant="outline"
            className="h-8 w-8 p-0"
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
          >
            <span className="sr-only">Go to previous page</span>
            <ChevronLeftIcon className="h-4 w-4" />
          </Button>
          <Button
            variant="outline"
            className="h-8 w-8 p-0"
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
          >
            <span className="sr-only">Go to next page</span>
            <ChevronRightIcon className="h-4 w-4" />
          </Button>
          <Button
            variant="outline"
            className="hidden h-8 w-8 p-0 lg:flex"
            onClick={() => table.setPageIndex(table.getPageCount() - 1)}
            disabled={!table.getCanNextPage()}
          >
            <span className="sr-only">Go to last page</span>
            <DoubleArrowRightIcon className="h-4 w-4" />
          </Button>
        </div>
      </div>
    </div>
  </div>
);

基本的には、ドキュメントに記載されてある Table API の諸々の関数を利用してるだけなのですが、下記に少しだけ説明を載せておきます。

https://tanstack.com/table/latest/docs/api/features/pagination

  • getFilteredSelectedRowModel
    • フィルターされた行のうち、選択済み(ここでは表示されているページ)の行データを返す
  • getFilteredRowModel
    • フィルターされた行データを返す
  • setPageSize
    • 1 ページあたりのサイズを選択
    • デフォルトは 10
  • getPageCount
    • 現在のページ数を返す
  • setPageIndex
    • 入力されたページへ状態を遷移
  • previousPage / nextPage
    • 前のページ・次のページへ状態を遷移
  • getCanPreviousPage / getCanNextPage
    • 前のページ・次のページが存在しているか?

カラムの表示・非表示の切り替え

次に「カラムの表示・非表示の切り替え」です。

Image from Gyazo

https://github.com/youliangdao/tanstack-table-design-pattern/tree/main/visibility-table/src

こちらも基本的にはフィルターとソートと同様に状態をテーブル外で定義します。

data-tables.tsx
  import {
    ...
+   VisibilityState,
    useReactTable,
  } from "@tanstack/react-table";

+ import {
+   DropdownMenu,
+   DropdownMenuCheckboxItem,
+   DropdownMenuTrigger,
+   DropdownMenuContent,
+ } from "../components/ui/dropdown-menu";

  interface DataTableProps<TData, TValue> {
    columns: ColumnDef<TData, TValue>[];
    data: TData[];
  }

  export function DataTable<TData, TValue>({
    columns,
    data,
  }: DataTableProps<TData, TValue>) {
    const [sorting, setSorting] = useState<SortingState>([]);
    const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
+   const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
    const table = useReactTable({
      data,
      columns,
      ...
+     onColumnVisibilityChange: setColumnVisibility,
      state: {
        sorting,
        columnFilters,
+       columnVisibility,
      },
    });

    return (
      <div className="space-y-4">
        ...
+       <DropdownMenu>
+         <DropdownMenuTrigger asChild>
+            <Button variant="outline" className="ml-auto">
+             Columns
+           </Button>
+         </DropdownMenuTrigger>
+         <DropdownMenuContent align="end">
+           {table
+             .getAllColumns()
+             .filter((column) => column.getCanHide())
+             .map((column) => {
+                return (
+                 <DropdownMenuCheckboxItem
+                  key={column.id}
+                   className="capitalize"
+                   checked={column.getIsVisible()}
+                   onCheckedChange={(value) =>
+                     column.toggleVisibility(!!value)
+                   }
+                 >
+                   {column.id}
+                 </DropdownMenuCheckboxItem>
+               );
+             })}
+         </DropdownMenuContent>
+       </DropdownMenu>
        ...
      </div>
    );
  }

Column API の toggleVisibility を用いてカラムの表示・非表示を切り替えています。その他の API については下記ドキュメントを参照してください。

https://tanstack.com/table/latest/docs/api/features/column-visibility#getrightvisibleleafcolumns

カラムの並び替え(ドラッグ&ドロップ)

少しややこしいのが、カラムの並び替え(ドラッグ&ドロップ)です。
というのも、こちらの実装には DnD のライブラリ(今回は dnd-kit)を扱う必要があるからです。

https://dndkit.com/

詳細は割愛しますが、少しだけ使い方を説明しておきます。

サンプルをご覧ください。

Image from Gyazo

https://github.com/youliangdao/tanstack-table-design-pattern/tree/main/column-dnd-table/src

まずさきほどまでと同様にcolumnOrderという状態を定義してます。
そしてuseReactTableのオプションに渡して、Tanstack Table 側とつなぎこみます。ここまでは大丈夫でしょう。


ここから少しややこしいところで、ドラッグ&ドロップを実現させるためにいくつか独特な記述が存在しています。

具体的には下記あたりですね。

data-table-column-header.tsx
export const DraggableTableHeader = <TData,>({
  header,
}: {
  header: Header<TData, unknown>;
}) => {
  const { attributes, isDragging, listeners, setNodeRef, transform } =
    useSortable({
      id: header.column.id,
    });

  const style: CSSProperties = {
    opacity: isDragging ? 0.8 : 1,
    position: "relative",
    transform: CSS.Translate.toString(transform),
    transition: "width transform 0.2s ease-in-out",
    whiteSpace: "nowrap",
    width: header.column.getSize(),
    zIndex: isDragging ? 1 : 0,
  };

  return (
    <TableHead colSpan={header.colSpan} ref={setNodeRef} style={style}>
      {header.isPlaceholder
        ? null
        : flexRender(header.column.columnDef.header, header.getContext())}
      <button {...attributes} {...listeners}>
        🟰
      </button>
    </TableHead>
  );
};
data-table.tsx
const DragAlongCell = <TData,>({ cell }: { cell: Cell<TData, unknown> }) => {
  const { isDragging, setNodeRef, transform } = useSortable({
    id: cell.column.id,
  });

  const style: CSSProperties = {
    opacity: isDragging ? 0.8 : 1,
    position: "relative",
    transform: CSS.Translate.toString(transform), // translate instead of transform to avoid squishing
    transition: "width transform 0.2s ease-in-out",
    width: cell.column.getSize(),
    zIndex: isDragging ? 1 : 0,
  };

  return (
    <TableCell style={style} ref={setNodeRef}>
      {flexRender(cell.column.columnDef.cell, cell.getContext())}
    </TableCell>
  );
  • DraggableTableHeader
    • ドラッグ&ドロップのつまみとヘッダーが合わさったコンポーネント
    • dnd-kit が提供している Sortable プリセットを用いて実装されている
  • DragAlognCell
    • ドラッグ&ドロップされるセル部分のコンポーネント
  • useSortable
    • 並び替え可能な要素にドラッグアンドドロップの機能を付与することができるカスタムフック。このフックを使うことで、要素が draggable になり、その要素の状態や属性(ドラッグ中かどうかなど)を管理することができる。
    • 引数にユニークな値の id を渡して、setNodeRefattributeslistenerstransformtransitionisDragging を受け取る。各プロパティの役割は以下のとおり。
  • setNodeRef
    • DOM 要素への参照を設定するために使用される。これにより、dnd-kit は、Sortable のドラッグアンドドロップにその要素を正確に追跡することができる。
  • attributes
    • アクセシビリティや HTML の属性を適切に設定するために使用される。
  • listeners
    • ドラッグ操作の開始や終了するためのマウスやタッチイベントのリスナーを含んでいる。
  • transform
    • アイテムがドラッグ操作中にどのように移動するかを定義する CSS 変換のオブジェクト。
    • ドラッグ&ドロップ可能なヘッダーの視覚的な動きを再現するために用いられている
  • isDragging
    • アイテムが現在ドラッグされているかを示す boolean

上記のように定義したコンポーネントをDataTable内で使用します。

data-table.tsx
  export function DataTable<TData, TValue>({
    columns,
    data,
  }: DataTableProps<TData, TValue>) {
    ...
+   function handleDragEnd(event: DragEndEvent) {
+     const { active, over } = event;
+     if (active && over && active.id !== over.id) {
+       setColumnOrder((columnOrder) => {
+         const oldIndex = columnOrder.indexOf(active.id as string);
+         const newIndex = columnOrder.indexOf(over.id as string);
+         return arrayMove(columnOrder, oldIndex, newIndex); //this is just a splice util
+       });
+     }
+   }

+   const sensors = useSensors(
+     useSensor(MouseSensor, {}),
+     useSensor(TouchSensor, {}),
+     useSensor(KeyboardSensor, {})
+   );

    return (
+     <DndContext
+       collisionDetection={closestCenter}
+       modifiers={[restrictToHorizontalAxis]}
+       onDragEnd={handleDragEnd}
+       sensors={sensors}
+     >
       <div className="space-y-4">
          ...
          <div className="rounded-md border">
            ...
+               <SortableContext
+                 items={columnOrder}
+                 strategy={horizontalListSortingStrategy}
+               >
+               {table.getHeaderGroups().map((headerGroup) => (
+                 <TableRow key={headerGroup.id}>
+                   {headerGroup.headers.map((header) => {
+                     return (
+                       <DraggableTableHeader key={header.id} header={header} />
+                     );
+                   })}
+                 </TableRow>
+               ))}
+               </SortableContext>
                ...
                {row.getVisibleCells().map((cell) => (
+                 <SortableContext
+                   key={cell.id}
+                   items={columnOrder}
+                   strategy={horizontalListSortingStrategy}
+                 >
+                   <DragAlongCell key={cell.id} cell={cell} />
+                 </SortableContext>
+               ))}
                ...
              </TableBody>
            </Table>
          </div>
          ...
        </div>
+     </DndContext>
    );
  }
  • DndContext
    • dnd kit の中心的なコンポーネントで、ドラッグ&ドロップの動作のコンテキストを提供する。
    • このコンポーネントの中にドラッグ&ドロップ可能な要素を配置することで、ドラッグ&ドロップのインタラクションが有効になる。
  • SortableContext
    • 並び替え可能な要素のコレクションを管理するプロバイダー。
  • handleDragEnd
    • ドラッグ操作が終了したときに呼び出されるイベントハンドラ
    • イベントオブジェクト event を引数として受け取り、イベントオブジェクトから active(ドラッグされたアイテム)と over(ドラッグしていたアイテムがドロップされた先のアイテム)の情報を取得する
    • activeover を使い移動前の index と、移動先の index を取得し、それを@dnd-kit/sortable の arrayMove を使い、アイテムの順序を変更する
  • useSensors
    • 異なる種類の入力方法(マウス、タッチ、キーボード)をどのように検知し、処理するかを定義している
    • MouseSensor
      • マウスの動きを検知する。
      • ユーザーがマウスを使ってドラッグ&ドロップ操作を行う際に反応する
    • TouchSensor
      • タッチスクリーンでのタッチ操作を検知する。
      • モバイルデバイスやタッチスクリーン対応のデスクトップでの操作に対応する
    • KeyboardSensor
      • キーボード操作を検知する。
      • キーボードを使ったドラッグ&ドロップ操作(例:矢印キーでの移動、スペースキーでの選択など)に対応する

固定カラム

最後は固定カラムです。

サンプルからまずはご覧ください。

Image from Gyazo

https://github.com/youliangdao/tanstack-table-design-pattern/tree/main/column-pinning-table/src

重要なのは下記の部分です。

data-table.tsx
const table = useReactTable({
  //...
  initialState: {
    columnPinning: {
      left: ["id"],
    },
    //...
  }
  //...
});

よくあるユースケースとして考えられるのは、カラムを最初からデフォルトでピン留めしている場合です。
この場合、initialStateオプションを使用して初期値として固定したいカラムの ID を指定します。

ちなみに、ソートやフィルタと同様にカラム固定に関してもcolumnPiningという状態が存在しており、もし固定カラムを動的に変更する場合などはこちらを使用します。

const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
  left: [],
  right: [],
});

2. 行に関する操作関連

次に、行に関する操作が絡む例を実装例とともに紹介していきます。

インライン編集

まずはインライン編集です。

インライン編集を行うためには、セルを入力可能なフィールドに置き換える必要があります。

そのため、まずは入力フィールドを持つセル用のコンポーネントを新しく作成します。

data-table-editable-cell.tsx
import { Input } from "../components/ui/input";
import { useState } from "react";

const EditableCell = () => {
  const [value, setValue] = useState("")
  return <Input value={value} onChange={e => setValue(e.target.value)} />
}

そして、カラム定義に新しく cell プロパティを追加し、先ほど作成したセル用のコンポーネントを追加します。基本的な使い方で少し触れた通り、ColumnDefではセルのカスタマイズも可能です。

columns.tsx
  export const columns: ColumnDef<Person>[] = [
    {
      accessorKey: "id",
      id: "id",
      header: ({ column }) => {
        ...
      },
+     cell: EditableCell,
    },
    {
      accessorKey: "firstName",
      id: "firstName",
      header: ({ column }) => {
        ...
      },
+     cell: EditableCell,
    },
    {
      accessorKey: "lastName",
      id: "lastName",
      header: ({ column }) => {
        ...
      },
+     cell: EditableCell,
    },
    {
      accessorKey: "age",
      id: "age",
      header: ({ column }) => {
        ...
      },
+     cell: EditableCell,
    },
    {
      accessorKey: "category",
      id: "category",
      header: ({ column }) => {
        ...
      },
+     cell: EditableCell,
    },
    {
      accessorKey: "skills",
      id: "skills",
      header: ({ column }) => {
        ...
      },
+     cell: EditableCell,
    },
    {
      accessorKey: "status",
      id: "status",
      header: ({ column }) => {
        ...
      },
+     cell: EditableCell,
    },
  ];

ただしこれだけだと変更された値に基づいたデータ更新の処理が未実装ですので、DataTable コンポーネントに新しく関数を作成してデータ更新の処理を追加します。

ここではmetaオプションを用いてuseReactTableにそのまま処理を追加します。

data-table.tsx
export function DataTable<TData, TValue>({
  columns,
  defaultData,
}: DataTableProps<TData, TValue>) {
  const [data, setData] = useState(() => [...defaultData]);
  const table = useReactTable({
    data,
    columns,
    ...
    meta: {
      updateData: (rowIndex: number, columnId: string, value: string) => {
        setData((old) =>
          old.map((row, index) => {
            if (index === rowIndex) {
              return {
                ...old[rowIndex],
                [columnId]: value,
              };
            }
            return row;
          })
        );
      },
    },
  });
  return (...);

追加した関数は、行のインデックス、列 ID、更新した値の 3 つのパラメーターを持つ関数で、table.option.metaと呼び出すことでテーブルインスタンスが利用可能な場所であればどこでもアクセス可能になっています。

詳細は下記ドキュメントを参考にしてください。

https://tanstack.com/table/latest/docs/api/core/table#meta

最後にこれらを用いてセル用コンポーネントに処理を追加して、更新用の関数をトリガーしましょう。

data-table-editable-cell.tsx
export const EditableCell = ({ getValue, row, column, table }) => {
  const initialValue = getValue();
  const [value, setValue] = useState(initialValue);
  useEffect(() => {
    setValue(initialValue);
  }, [initialValue]);
  const onBlur = () => {
    table.options.meta?.updateData(row.index, column.id, value);
  };
  return (
    <Input
      value={value}
      onChange={(e) => setValue(e.target.value)}
      onBlur={onBlur}
    />
  );
};

onBlur を使って updateData 関数をトリガーしています。さらに、getValue を使って入力フィールドにデフォルト値を渡すようにしています。

Image from Gyazo

https://github.com/youliangdao/tanstack-table-design-pattern/tree/main/editable-table/src

行の追加と削除、複数行選択

次は行の追加、削除、複数選択です。ここはまとめて紹介させていただきます。

行の追加からみていきましょう。


テーブルの行を追加できるようにするには、「行の追加」ボタンを追加し、空の行をデータ配列に挿入するロジックを作成します

インライン編集のときと同様に、useReactTablemeta に関数をアタッチします。 こうすることで、テーブルインスタンスを通して、どこでも行追加ロジックにアクセスできるようになります。

  const table = useReactTable({
    data,
    columns,
    ...
    meta: {
      updateData: (...) => {
        ...
      },
      addRow: () => {
        setData((old) => [
          ...old,
          {
            id: Math.floor(Math.random() * 10000).toString(),
            firstName: "",
            lastName: "",
            age: 0,
            category: "その他",
            skills: "",
            status: "ToDo",
          } as TData,
        ]);
      },
    },
  });

さらに、addRow関数を使用するためのボタンを作成します。

data-table-column-footer
import { Button } from "../components/ui/button";

export const FooterCell = ({ table }) => {
  const meta = table.options.meta;
  return (
    <div>
      <Button onClick={meta?.addRow} variant="outline">
        Add New +
      </Button>
    </div>
  );
};

最後にこのフッターをテーブルに表示します。

動作のイメージとしては下記のような形になります。

Image from Gyazo


次にテーブルの行の削除です。
基本的には行の追加と同じで、removeRowという関数をuseReactTablemeta にアタッチすることから始めます。

data-table.tsx
  const table = useReactTable({
    data,
    columns,
    ...
    meta: {
      updateData: (...) => {
        ...
      },
      addRow: () => {
        ...
      },
      removeRow: (rowIndex: number) => {
        setData((old) => old.filter((_row, index) => index !== rowIndex));
      },
    },
  });

removeRow関数を呼び出すためのセルをステータスカラムの隣に作成します。

data-table-remove-row.tsx
import { Button } from "../components/ui/button";

export const RemoveRowCell = ({ row, table }) => {
  return (
    <Button
      onClick={() => table.options.meta?.removeRow(row.index)}
      variant="destructive"
    >
      Remove
    </Button>
  );
};

動作イメージとしては下記のような形になります。

Image from Gyazo


最後に複数行選択とそれらの削除を行います。

まず、enableRowSelectionuseReactTable に追加し、新たに removeSelectedRows 関数を作成します。 この関数は、選択された行 ID の配列を受け取り、data 配列からそれらをフィルタリングします。

enableRowSelectionは、テーブルの行選択を可能にするオプションです。

https://tanstack.com/table/latest/docs/api/features/row-selection

data-table.tsx
const table = useReactTable({
  data,
  columns,
  ...
  enableRowSelection: true,
  meta: {
    revertData: (...) => {
      ...
    },
    updateData: (...) => {
      ...
    },
    addRow: () => {
      ...
    },
    removeRow: (...) => {
      ...
    },
    removeSelectedRows: (selectedRows: number[]) => {
      const setFilterFunc = (old: Student[]) =>
        old.filter((_row, index) => !selectedRows.includes(index));
      setData(setFilterFunc);
      setOriginalData(setFilterFunc);
    },
  },
});

次に、RemoveCell コンポーネントに、どの行が選択されているのかを判別するためチェックボックスを追加します。

data-table-remove-row.tsx
  import { Button } from "../components/ui/button";
  import { Input } from "../components/ui/input";

  export const RemoveRowCell = ({ row, table }) => {
    return (
      <div className="flex items-center space-x-2 mr-5">
        <Button
          onClick={() => table.options.meta?.removeRow(row.index)}
          variant="destructive"
        >
          Remove
        </Button>
+       <Input
+         type="checkbox"
+         checked={row.getIsSelected()}
+         onChange={row.getToggleSelectedHandler()}
+         className="w-4 h-4"
+       />
      </div>
    );
  };

  • row.getIsSelected
    • その行が選択されているかどうか?を示すフラグ
  • row.getToggleSelectedHandler
    • その行の選択・非選択状態をトグルさせる

最後にフッターに複数選択時用の削除ボタンを用意して、removeSelectedRows関数をmetaオブジェクトから渡せるようにします。

ここで重要なのが、どの行が選択されているか?の情報はテーブルインスタンスのgetSelectedRowModelメソッドから得られるということです。

https://tanstack.com/table/latest/docs/api/core/table#getrowmodel

data-table-column-footer.tsx
  import { Button } from "../components/ui/button";

  export const FooterCell = ({ table }) => {
    const meta = table.options.meta;
+   const selectedRows = table.getSelectedRowModel().rows;
+    const removeRows = () => {
+     meta.removeSelectedRows(
+       table.getSelectedRowModel().rows.map((row) => row.index)
+     );
+     table.resetRowSelection();
+   };

    return (
      <div className="flex items-center justify-between space-x-4">
+       {selectedRows.length > 0 ? (
+         <Button variant="destructive" onClick={removeRows}>
+           Remove Selected x
+         </Button>
+       ) : null}
        <Button onClick={meta?.addRow} variant="outline">
          Add New +
        </Button>
      </div>
    );
  };

完成形は以下のとおりです。

Image from Gyazo

https://github.com/youliangdao/tanstack-table-design-pattern/tree/main/add-remove-multiple-table/src

おわりに

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

Tanstack Table の基本的な使い方からそれを応用したデータテーブルのよくある実装パターンまで見てきました。

本当はまだ紹介したいケース(たとえばパフォーマンス改善のための仮想化やフィルターやソートのクエリパラメーターへの保持など)はあるのですが、今回は割愛いたします。もし気が向いたら書くかもですが...

今回の記事が Tanstack Table の使い方やテーブルの実装に悩んでる方の参考になれば嬉しいです。

最後までご覧いただきありがとうございました!

参考文献

https://www.mcdigital.jp/blog/20240523techblog/

https://zenn.dev/nus3/scraps/6cc44935f469ce

https://coyleandrew.medium.com/design-better-data-tables-4ecc99d23356

https://bootcamp.uxdesign.cc/data-table-design-patterns-4e38188a0981

https://coyleandrew.medium.com/designing-tables-79a655ca183f

https://coyleandrew.medium.com/ui-considerations-for-designing-large-data-tables-aa6c1ea93797

https://bootcamp.uxdesign.cc/lets-design-better-tables-fb68627522f5

https://muhimasri.com/blogs/react-editable-table/#editable-rows

https://muhimasri.com/blogs/add-remove-react-table-rows/#remove-multiple-table-rows

脚注
  1. ここでのヘッドレス UI の意味を一言で説明すると「コンポーネントライブラリのように見た目を提供するのではなく、テーブルに必要なデータ処理や状態管理、ビジネスロジックなどを提供してくれる」ということになります。詳細は下記を参考にしてください。
    https://tanstack.com/table/latest/docs/introduction#what-is-headless-ui ↩︎

COUNTERWORKS テックブログ

Discussion