実装例から見る Tanstack Table の使い方
はじめに
弊社では商業施設向けの 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"
},
また、本記事で使用したコード群については下記リポジトリでまとめて公開しています。
最終的なディレクトリ構造は下記のようになっております。
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 などでも使用可能です。
また Tanstack Table をベースにデザインを当て込んだ UI ライブラリもいくつか存在しており、開発者によってコンポーネントベースとヘッドレス UI ベースを使い分けられるようになっています。
基本的な使い方
次に Tanstack Table の基本的な使い方を見ていきましょう。
まずは基本的なテーブルを作ることからはじめていきます。
下記はデータを表示するだけのベーシックなテーブルです。
(テーブルのスタイリングには shadcn/ui の Table コンポーネントを使用しています)
Tanstack Table を用いたテーブルの実装の流れは、次の 4 ステップに分かれます。
- テーブルデータの型を定義
- カラム定義を作成
useReactTable
を用いてテーブルの情報を取得- レンダリング
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 で定義したデータをテーブル用に変換する、といったイメージですね。
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 を設定するとブラウザのコンソール上にエラーが表示されてしまいます)
さらにカラム定義ではセルやヘッダー、フッターのフォーマットを自由に指定することも可能です。
今回はヘッダーにカスタムコンテンツを表示していますが、フッターやセルを自由にカスタマイズすることも可能です。
useReactTable
を用いてテーブルの情報を取得
3. 次にuseReactTable
に 1 で定義したデータと 2 で定義したカラムを渡し、テーブルの情報を取得します。
ここで React 側と接続しているイメージですね。
このフックの目的は "React" 方式 で状態を管理し、型を提供し、セル/ヘッダー/フッターのレンダリングを実装することになります。
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
ここでgetCoreRowModel
というオプションも設定しないとエラーになってしまうので注意が必要です。
このオプションは説明するのが少し難しいのですが、行データをどのように管理するのか?を示したモデルになります。
今回は、getCoreRowModel
というもっともベーシックなモデルを指定しています。これはテーブルに渡された元のデータを 1:1 にマッピングしただけの基本的な行モデルを返します。
getCoreRowModel
の他にもいくつか行モデルは存在しています。
ここでは代表的なものだけ挙げておきます。(後述の具体的な実装例で使用します)
行モデル | 説明 |
---|---|
getFilteredRowModel | フィルタリングを考慮した行モデルを返します。 |
getSortedRowModel | ソートが適用された行モデルを返します。 |
getExpandedRowModel | 展開/非表示のサブ行を考慮した行モデルを返します。 |
getPaginationRowModel | ページネーションの状態に基づいて現在のページに表示されるべき行のみを含む行モデルを返します。 |
4. レンダリング
最後に 3 で取得したテーブルの情報を元にレンダリングします。
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 で定義したコンポーネントがそれぞれの情報をレンダリングするといった仕組みです。
詳細はドキュメントなどを参考にしてください。
よくあるユースケースを Tanstack Table で実装してみる
ここからは上記のベーシックなテーブルを元に、実際によく使われるテーブルを実装例とともに紹介していきたいと思います。
Tanstack Table の使い方を学ぶにはサンプルコードを見るのが 1 番効果的だと思いますので、ぜひコードを見たりいじったりしながら参考にしてください。
1. カラムの操作関連(+ページネーション)
まずは使用頻度の高いカラム操作が絡む例を見ていきましょう。
ソート
最初はカラムのソートです。
ユーザーにとってデータの並び順を自由に操作したいというのはよくあるユースケースです。
以下がサンプルと実装例です。
重要な点としては、useReactTable
の引数に getSortedRowModel()
(=行モデル)を渡すことで、クライアント側でのソート機能を実現できるということです。
またソートの状態は SortingState
で表し、配列の先頭の要素から順に値をキーとして持つことでソートが行われます。
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[];
実際にソートの状態にアクセスすることはそこまでないと思いますが、クエリパラメーターにソート情報を保持することなどを求められた際には必要になってきますので、あわせて知っておくとよいでしょう。
今回はクエリパラメーターのサンプルは割愛いたしますが、気になる方は公式ドキュメントにサンプルコードがありますので、ぜひそちらを参考にしてください。
onSortingChange について
onSortingChange
はSortingState
が変化したときに内部的に呼び出されるものです。
今回はsorting
の状態をテーブルの外部で管理しているため、setSorting
を設定しています。
toggleSorting について
toggleSorting: (desc?: boolean, isMulti?: boolean) => void
toggleSorting
は Column の API でカラムのソート状態を切り替えるものです。
desc
が指定されている場合、ソートの方向を降順に強制します。
フィルター
次はフィルターです。
フィルターもソートと同様に使用頻度の高いカラム操作です。
以下がサンプルとその実装例です。
ソートと同様に、getFilteredRowModel()
という行モデルを指定して、さらにテーブル外(columnfilters
)でカラムフィルターの状態を制御しています。
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 カラムにしか有効にしていないということです。他のカラムでも同様のフィルタリングをカスタマイズできますが、ここでは割愛いたします(詳細はドキュメント等を参照してください)
その他重要なものについては下記にまとめています。
getFilterValue と setFilterValue について
どちらも Column の API であり、column オブジェクトが持っている関数です。
column.getFilterValue
は入力のデフォルトの初期フィルター値を取得したりするのに利用されます。
またcolumn.setFilterValue
は onChange または onBlur ハンドラーにフィルター入力を接続するのによく用いられ、フィルタリングする値を設定できます。
ページネーション
次はページネーションです。
テーブルの行数が多くなる場合に、複数のページに分けてページネーションを配置することはよくあるケースのひとつです。
下記がサンプルとその実装例になります。
ここも、getPaginationRowModel
という行モデルを指定しています。
ただしページネーションに関しては、組み込みの state と API で管理可能なので、とくに初期値などを別 state として定義していません。(ソートやフィルターのように useState
を用いて定義していない)
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 の諸々の関数を利用してるだけなのですが、下記に少しだけ説明を載せておきます。
-
getFilteredSelectedRowModel
- フィルターされた行のうち、選択済み(ここでは表示されているページ)の行データを返す
-
getFilteredRowModel
- フィルターされた行データを返す
-
setPageSize
- 1 ページあたりのサイズを選択
- デフォルトは 10
-
getPageCount
- 現在のページ数を返す
-
setPageIndex
- 入力されたページへ状態を遷移
-
previousPage / nextPage
- 前のページ・次のページへ状態を遷移
-
getCanPreviousPage / getCanNextPage
- 前のページ・次のページが存在しているか?
カラムの表示・非表示の切り替え
次に「カラムの表示・非表示の切り替え」です。
こちらも基本的にはフィルターとソートと同様に状態をテーブル外で定義します。
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 については下記ドキュメントを参照してください。
カラムの並び替え(ドラッグ&ドロップ)
少しややこしいのが、カラムの並び替え(ドラッグ&ドロップ)です。
というのも、こちらの実装には DnD のライブラリ(今回は dnd-kit)を扱う必要があるからです。
詳細は割愛しますが、少しだけ使い方を説明しておきます。
サンプルをご覧ください。
まずさきほどまでと同様にcolumnOrder
という状態を定義してます。
そしてuseReactTable
のオプションに渡して、Tanstack Table 側とつなぎこみます。ここまでは大丈夫でしょう。
ここから少しややこしいところで、ドラッグ&ドロップを実現させるためにいくつか独特な記述が存在しています。
具体的には下記あたりですね。
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>
);
};
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 を渡して、
setNodeRef
、attributes
、listeners
、transform
、transition
、isDragging
を受け取る。各プロパティの役割は以下のとおり。
-
setNodeRef
- DOM 要素への参照を設定するために使用される。これにより、dnd-kit は、Sortable のドラッグアンドドロップにその要素を正確に追跡することができる。
-
attributes
- アクセシビリティや HTML の属性を適切に設定するために使用される。
-
listeners
- ドラッグ操作の開始や終了するためのマウスやタッチイベントのリスナーを含んでいる。
-
transform
- アイテムがドラッグ操作中にどのように移動するかを定義する CSS 変換のオブジェクト。
- ドラッグ&ドロップ可能なヘッダーの視覚的な動きを再現するために用いられている
-
isDragging
- アイテムが現在ドラッグされているかを示す boolean
上記のように定義したコンポーネントをDataTable
内で使用します。
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
(ドラッグしていたアイテムがドロップされた先のアイテム)の情報を取得する -
active
、over
を使い移動前のindex
と、移動先のindex
を取得し、それを@dnd-kit/sortabl
e のarrayMove
を使い、アイテムの順序を変更する
-
useSensors
- 異なる種類の入力方法(マウス、タッチ、キーボード)をどのように検知し、処理するかを定義している
- MouseSensor
- マウスの動きを検知する。
- ユーザーがマウスを使ってドラッグ&ドロップ操作を行う際に反応する
- TouchSensor
- タッチスクリーンでのタッチ操作を検知する。
- モバイルデバイスやタッチスクリーン対応のデスクトップでの操作に対応する
- KeyboardSensor
- キーボード操作を検知する。
- キーボードを使ったドラッグ&ドロップ操作(例:矢印キーでの移動、スペースキーでの選択など)に対応する
固定カラム
最後は固定カラムです。
サンプルからまずはご覧ください。
重要なのは下記の部分です。
const table = useReactTable({
//...
initialState: {
columnPinning: {
left: ["id"],
},
//...
}
//...
});
よくあるユースケースとして考えられるのは、カラムを最初からデフォルトでピン留めしている場合です。
この場合、initialState
オプションを使用して初期値として固定したいカラムの ID を指定します。
ちなみに、ソートやフィルタと同様にカラム固定に関してもcolumnPining
という状態が存在しており、もし固定カラムを動的に変更する場合などはこちらを使用します。
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
left: [],
right: [],
});
2. 行に関する操作関連
次に、行に関する操作が絡む例を実装例とともに紹介していきます。
インライン編集
まずはインライン編集です。
インライン編集を行うためには、セルを入力可能なフィールドに置き換える必要があります。
そのため、まずは入力フィールドを持つセル用のコンポーネントを新しく作成します。
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
ではセルのカスタマイズも可能です。
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
にそのまま処理を追加します。
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
と呼び出すことでテーブルインスタンスが利用可能な場所であればどこでもアクセス可能になっています。
詳細は下記ドキュメントを参考にしてください。
最後にこれらを用いてセル用コンポーネントに処理を追加して、更新用の関数をトリガーしましょう。
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
を使って入力フィールドにデフォルト値を渡すようにしています。
行の追加と削除、複数行選択
次は行の追加、削除、複数選択です。ここはまとめて紹介させていただきます。
行の追加からみていきましょう。
テーブルの行を追加できるようにするには、「行の追加」ボタンを追加し、空の行をデータ配列に挿入するロジックを作成します
インライン編集のときと同様に、useReactTable
の meta
に関数をアタッチします。 こうすることで、テーブルインスタンスを通して、どこでも行追加ロジックにアクセスできるようになります。
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
関数を使用するためのボタンを作成します。
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>
);
};
最後にこのフッターをテーブルに表示します。
動作のイメージとしては下記のような形になります。
次にテーブルの行の削除です。
基本的には行の追加と同じで、removeRow
という関数をuseReactTable
の meta
にアタッチすることから始めます。
const table = useReactTable({
data,
columns,
...
meta: {
updateData: (...) => {
...
},
addRow: () => {
...
},
removeRow: (rowIndex: number) => {
setData((old) => old.filter((_row, index) => index !== rowIndex));
},
},
});
removeRow
関数を呼び出すためのセルをステータスカラムの隣に作成します。
import { Button } from "../components/ui/button";
export const RemoveRowCell = ({ row, table }) => {
return (
<Button
onClick={() => table.options.meta?.removeRow(row.index)}
variant="destructive"
>
Remove
</Button>
);
};
動作イメージとしては下記のような形になります。
最後に複数行選択とそれらの削除を行います。
まず、enableRowSelection
を useReactTable
に追加し、新たに removeSelectedRows
関数を作成します。 この関数は、選択された行 ID の配列を受け取り、data 配列からそれらをフィルタリングします。
enableRowSelection
は、テーブルの行選択を可能にするオプションです。
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 コンポーネントに、どの行が選択されているのかを判別するためチェックボックスを追加します。
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
メソッドから得られるということです。
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>
);
};
完成形は以下のとおりです。
おわりに
いかがでしたでしょうか?
Tanstack Table の基本的な使い方からそれを応用したデータテーブルのよくある実装パターンまで見てきました。
本当はまだ紹介したいケース(たとえばパフォーマンス改善のための仮想化やフィルターやソートのクエリパラメーターへの保持など)はあるのですが、今回は割愛いたします。もし気が向いたら書くかもですが...
今回の記事が Tanstack Table の使い方やテーブルの実装に悩んでる方の参考になれば嬉しいです。
最後までご覧いただきありがとうございました!
参考文献
-
ここでのヘッドレス UI の意味を一言で説明すると「コンポーネントライブラリのように見た目を提供するのではなく、テーブルに必要なデータ処理や状態管理、ビジネスロジックなどを提供してくれる」ということになります。詳細は下記を参考にしてください。
https://tanstack.com/table/latest/docs/introduction#what-is-headless-ui ↩︎
![COUNTERWORKS テックブログ](https://storage.googleapis.com/zenn-user-upload/avatar/a7a000e525.jpeg)
ポップアップストアや催事イベント向けの商業スペースを簡単に予約できる「SHOPCOUNTER」と商業施設向けリーシングDXシステム「SHOPCOUNTER Enterprise」を運営しています。エンジニア採用強化中ですので、興味ある方はお気軽にご連絡ください! counterworks.co.jp/
Discussion