はじめに

Reactでデータテーブルを構築する際、ソート、フィルター、ページネーションといった機能を一から実装するのは大変な労力を要します。TanStack Tableは、これらの複雑なテーブル機能を宣言的に実装できるHeadless UIライブラリです。

TanStack Table(旧React Table)は、マークアップやスタイルを持たない「Headless UI」アプローチを採用しています。テーブルのロジック、状態管理、データ処理を担当し、UIの実装は開発者に委ねることで、完全なカスタマイズ性を実現しています。

本記事では、TanStack Tableを使ったReactでの高機能データテーブル構築方法を解説します。

本記事を読むことで、以下のことができるようになります。

  • TanStack Tableの基本概念とセットアップ方法の理解
  • 型安全なテーブル実装
  • ソート、フィルター、ページネーション機能の実装
  • TanStack Virtualを使った大量データの仮想化対応
  • shadcn/uiとの統合によるモダンなUI構築

実行環境・前提条件

必要な環境

項目 バージョン
Node.js 18.0以上
React 18.0以降
TypeScript 5.x
@tanstack/react-table 8.x
VS Code 最新版推奨

前提知識

  • Reactの基本的なHooks(useState、useMemo)
  • TypeScriptの基本構文
  • HTML tableタグの基礎

TanStack Tableの概要と特徴

Headless UIとは

Headless UIとは、ロジック、状態、イベント処理、データ管理を提供しつつ、マークアップやスタイルは提供しないライブラリのことです。TanStack Tableは、このHeadless UIアプローチを採用しています。

graph TB
    subgraph "Headless UI(TanStack Table)"
        A[状態管理] --> D[テーブルインスタンス]
        B[データ処理] --> D
        C[イベントハンドラ] --> D
    end
    subgraph "開発者が実装"
        E[HTML/JSX]
        F[CSS/Tailwind]
        G[UIライブラリ]
    end
    D --> E
    D --> F
    D --> G

Component-based vs Headless

データテーブルライブラリは大きく2種類に分類されます。

Component-basedライブラリ(例: AG Grid)

  • すぐに使えるマークアップとスタイルを提供
  • セットアップが少ない
  • カスタマイズ性は限定的
  • バンドルサイズが大きい

Headlessライブラリ(TanStack Table)

  • マークアップとスタイルの完全な制御
  • すべてのスタイリングパターンに対応(CSS、CSS-in-JS、UIライブラリなど)
  • 軽量なバンドルサイズ
  • セットアップに手間がかかる

デザインの自由度を重視する場合や、既存のデザインシステムと統合する必要がある場合は、TanStack Tableが適しています。

主な機能

TanStack Tableは以下の機能を提供します。

機能 説明
ソート 単一列・複数列のソートに対応
フィルタリング 列フィルター・グローバルフィルターに対応
ページネーション クライアントサイド・サーバーサイド両対応
行選択 チェックボックスによる行選択
列の表示/非表示 動的な列の表示切り替え
列のリサイズ ドラッグによる列幅調整
グルーピング データのグループ化と集計
展開 サブ行の展開/折りたたみ

セットアップ手順

パッケージのインストール

TanStack TableのReactアダプターをインストールします。

1
npm install @tanstack/react-table

プロジェクト構成

本記事では以下の構成でサンプルコードを解説します。

src/
├── components/
│   └── DataTable/
│       ├── DataTable.tsx
│       └── columns.tsx
├── types/
│   └── user.ts
└── App.tsx

基本的なテーブル実装例

データ型の定義

まず、テーブルに表示するデータの型を定義します。

1
2
3
4
5
6
7
8
// src/types/user.ts
export type User = {
  id: number;
  name: string;
  email: string;
  age: number;
  status: "active" | "inactive";
};

列定義の作成

TanStack Tableでは、ColumnDefを使って列を定義します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// src/components/DataTable/columns.tsx
import { ColumnDef } from "@tanstack/react-table";
import { User } from "../../types/user";

export const columns: ColumnDef<User>[] = [
  {
    accessorKey: "id",
    header: "ID",
  },
  {
    accessorKey: "name",
    header: "名前",
  },
  {
    accessorKey: "email",
    header: "メールアドレス",
  },
  {
    accessorKey: "age",
    header: "年齢",
  },
  {
    accessorKey: "status",
    header: "ステータス",
    cell: ({ row }) => {
      const status = row.getValue("status") as string;
      return (
        <span
          className={`px-2 py-1 rounded text-sm ${
            status === "active"
              ? "bg-green-100 text-green-800"
              : "bg-gray-100 text-gray-800"
          }`}
        >
          {status === "active" ? "有効" : "無効"}
        </span>
      );
    },
  },
];

accessorKeyはデータオブジェクトのプロパティ名を指定し、headerは列ヘッダーのラベルを定義します。cellプロパティを使うことで、セルのレンダリングをカスタマイズできます。

テーブルコンポーネントの実装

useReactTableフックを使ってテーブルインスタンスを作成し、マークアップをレンダリングします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// src/components/DataTable/DataTable.tsx
import {
  useReactTable,
  getCoreRowModel,
  flexRender,
} from "@tanstack/react-table";
import { columns } from "./columns";
import { User } from "../../types/user";

type DataTableProps = {
  data: User[];
};

export function DataTable({ data }: DataTableProps) {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <div className="rounded-md border">
      <table className="w-full">
        <thead className="bg-gray-50">
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th
                  key={header.id}
                  className="px-4 py-3 text-left text-sm font-medium text-gray-900"
                >
                  {header.isPlaceholder
                    ? null
                    : flexRender(
                        header.column.columnDef.header,
                        header.getContext()
                      )}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {table.getRowModel().rows.map((row) => (
            <tr key={row.id} className="border-t">
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id} className="px-4 py-3 text-sm text-gray-700">
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

テーブルの使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// src/App.tsx
import { DataTable } from "./components/DataTable/DataTable";
import { User } from "./types/user";

const users: User[] = [
  { id: 1, name: "田中太郎", email: "tanaka@example.com", age: 28, status: "active" },
  { id: 2, name: "佐藤花子", email: "sato@example.com", age: 34, status: "active" },
  { id: 3, name: "鈴木一郎", email: "suzuki@example.com", age: 45, status: "inactive" },
];

function App() {
  return (
    <div className="container mx-auto py-10">
      <h1 className="text-2xl font-bold mb-4">ユーザー一覧</h1>
      <DataTable data={users} />
    </div>
  );
}

export default App;

ソート・フィルター・ページネーションの実装例

ソート機能の実装

ソート機能を追加するには、getSortedRowModelを使用し、ソート状態を管理します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
import { useState } from "react";
import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  flexRender,
  SortingState,
} from "@tanstack/react-table";
import { columns } from "./columns";
import { User } from "../../types/user";

type DataTableProps = {
  data: User[];
};

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

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

  return (
    <div className="rounded-md border">
      <table className="w-full">
        <thead className="bg-gray-50">
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th
                  key={header.id}
                  className="px-4 py-3 text-left text-sm font-medium text-gray-900 cursor-pointer select-none"
                  onClick={header.column.getToggleSortingHandler()}
                >
                  <div className="flex items-center gap-2">
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                    {{
                      asc: " ↑",
                      desc: " ↓",
                    }[header.column.getIsSorted() as string] ?? null}
                  </div>
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {table.getRowModel().rows.map((row) => (
            <tr key={row.id} className="border-t hover:bg-gray-50">
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id} className="px-4 py-3 text-sm text-gray-700">
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

ヘッダーをクリックすると、昇順 → 降順 → ソートなし の順でソート状態が切り替わります。Shiftキーを押しながらクリックすると、複数列でのソートが可能です。

フィルター機能の実装

列フィルターを実装するには、getFilteredRowModelを使用します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import { useState } from "react";
import {
  useReactTable,
  getCoreRowModel,
  getFilteredRowModel,
  getSortedRowModel,
  flexRender,
  SortingState,
  ColumnFiltersState,
} from "@tanstack/react-table";
import { columns } from "./columns";
import { User } from "../../types/user";

type DataTableProps = {
  data: User[];
};

export function DataTable({ data }: DataTableProps) {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    state: {
      sorting,
      columnFilters,
    },
  });

  return (
    <div className="space-y-4">
      {/* フィルター入力 */}
      <div className="flex items-center gap-4">
        <input
          type="text"
          placeholder="名前で検索..."
          value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
          onChange={(e) =>
            table.getColumn("name")?.setFilterValue(e.target.value)
          }
          className="px-3 py-2 border rounded-md w-64"
        />
        <select
          value={(table.getColumn("status")?.getFilterValue() as string) ?? ""}
          onChange={(e) =>
            table.getColumn("status")?.setFilterValue(e.target.value || undefined)
          }
          className="px-3 py-2 border rounded-md"
        >
          <option value="">すべてのステータス</option>
          <option value="active">有効</option>
          <option value="inactive">無効</option>
        </select>
      </div>

      {/* テーブル */}
      <div className="rounded-md border">
        <table className="w-full">
          {/* ... 省略(前述のソート機能付きテーブルと同じ) */}
        </table>
      </div>
    </div>
  );
}

列定義にカスタムフィルター関数を指定することもできます。

1
2
3
4
5
6
// columns.tsx
{
  accessorKey: "status",
  header: "ステータス",
  filterFn: "equals", // 完全一致フィルター
}

TanStack Tableには10種類の組み込みフィルター関数が用意されています。

フィルター関数 説明
includesString 大文字小文字を区別しない部分一致
equalsString 大文字小文字を区別しない完全一致
arrIncludes 配列内に値が含まれるか
inNumberRange 数値範囲内か
equals 参照の等価性

ページネーション機能の実装

ページネーションを実装するには、getPaginationRowModelを使用します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
import { useState } from "react";
import {
  useReactTable,
  getCoreRowModel,
  getFilteredRowModel,
  getSortedRowModel,
  getPaginationRowModel,
  flexRender,
  SortingState,
  ColumnFiltersState,
  PaginationState,
} from "@tanstack/react-table";
import { columns } from "./columns";
import { User } from "../../types/user";

type DataTableProps = {
  data: User[];
};

export function DataTable({ data }: DataTableProps) {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
  const [pagination, setPagination] = useState<PaginationState>({
    pageIndex: 0,
    pageSize: 10,
  });

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    onPaginationChange: setPagination,
    state: {
      sorting,
      columnFilters,
      pagination,
    },
  });

  return (
    <div className="space-y-4">
      {/* フィルター入力 */}
      <div className="flex items-center gap-4">
        <input
          type="text"
          placeholder="名前で検索..."
          value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
          onChange={(e) =>
            table.getColumn("name")?.setFilterValue(e.target.value)
          }
          className="px-3 py-2 border rounded-md w-64"
        />
      </div>

      {/* テーブル */}
      <div className="rounded-md border">
        <table className="w-full">
          <thead className="bg-gray-50">
            {table.getHeaderGroups().map((headerGroup) => (
              <tr key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <th
                    key={header.id}
                    className="px-4 py-3 text-left text-sm font-medium text-gray-900 cursor-pointer"
                    onClick={header.column.getToggleSortingHandler()}
                  >
                    <div className="flex items-center gap-2">
                      {flexRender(
                        header.column.columnDef.header,
                        header.getContext()
                      )}
                      {{
                        asc: " ↑",
                        desc: " ↓",
                      }[header.column.getIsSorted() as string] ?? null}
                    </div>
                  </th>
                ))}
              </tr>
            ))}
          </thead>
          <tbody>
            {table.getRowModel().rows.map((row) => (
              <tr key={row.id} className="border-t hover:bg-gray-50">
                {row.getVisibleCells().map((cell) => (
                  <td key={cell.id} className="px-4 py-3 text-sm text-gray-700">
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </td>
                ))}
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {/* ページネーションコントロール */}
      <div className="flex items-center justify-between">
        <div className="text-sm text-gray-700">
          {table.getFilteredRowModel().rows.length} 件中{" "}
          {pagination.pageIndex * pagination.pageSize + 1} -{" "}
          {Math.min(
            (pagination.pageIndex + 1) * pagination.pageSize,
            table.getFilteredRowModel().rows.length
          )}{" "}
          件を表示
        </div>
        <div className="flex items-center gap-2">
          <button
            onClick={() => table.firstPage()}
            disabled={!table.getCanPreviousPage()}
            className="px-3 py-1 border rounded disabled:opacity-50"
          >
            最初
          </button>
          <button
            onClick={() => table.previousPage()}
            disabled={!table.getCanPreviousPage()}
            className="px-3 py-1 border rounded disabled:opacity-50"
          >
            前へ
          </button>
          <span className="px-3 py-1">
            {pagination.pageIndex + 1} / {table.getPageCount()}
          </span>
          <button
            onClick={() => table.nextPage()}
            disabled={!table.getCanNextPage()}
            className="px-3 py-1 border rounded disabled:opacity-50"
          >
            次へ
          </button>
          <button
            onClick={() => table.lastPage()}
            disabled={!table.getCanNextPage()}
            className="px-3 py-1 border rounded disabled:opacity-50"
          >
            最後
          </button>
          <select
            value={pagination.pageSize}
            onChange={(e) => table.setPageSize(Number(e.target.value))}
            className="px-3 py-1 border rounded"
          >
            {[10, 20, 30, 50, 100].map((size) => (
              <option key={size} value={size}>
                {size}件表示
              </option>
            ))}
          </select>
        </div>
      </div>
    </div>
  );
}

仮想化による大量データ対応

数万件以上のデータを扱う場合、すべての行をDOMにレンダリングするとパフォーマンスが低下します。TanStack Virtualと組み合わせることで、表示領域内の行のみをレンダリングする仮想化を実現できます。

TanStack Virtualのインストール

1
npm install @tanstack/react-virtual

仮想化テーブルの実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import { useRef } from "react";
import {
  useReactTable,
  getCoreRowModel,
  getSortedRowModel,
  flexRender,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import { columns } from "./columns";
import { User } from "../../types/user";

type VirtualizedTableProps = {
  data: User[];
};

export function VirtualizedTable({ data }: VirtualizedTableProps) {
  const tableContainerRef = useRef<HTMLDivElement>(null);

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
  });

  const { rows } = table.getRowModel();

  const rowVirtualizer = useVirtualizer({
    count: rows.length,
    estimateSize: () => 48, // 行の高さ(px)
    getScrollElement: () => tableContainerRef.current,
    overscan: 10, // 表示領域外に事前レンダリングする行数
  });

  return (
    <div
      ref={tableContainerRef}
      className="rounded-md border overflow-auto"
      style={{ height: "500px" }}
    >
      <table className="w-full">
        <thead className="bg-gray-50 sticky top-0">
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th
                  key={header.id}
                  className="px-4 py-3 text-left text-sm font-medium text-gray-900"
                >
                  {flexRender(
                    header.column.columnDef.header,
                    header.getContext()
                  )}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {/* 仮想化された行のみレンダリング */}
          <tr style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
            <td colSpan={columns.length} className="p-0">
              <div className="relative w-full">
                {rowVirtualizer.getVirtualItems().map((virtualRow) => {
                  const row = rows[virtualRow.index];
                  return (
                    <div
                      key={row.id}
                      className="absolute w-full flex border-b"
                      style={{
                        height: `${virtualRow.size}px`,
                        transform: `translateY(${virtualRow.start}px)`,
                      }}
                    >
                      {row.getVisibleCells().map((cell) => (
                        <div
                          key={cell.id}
                          className="px-4 py-3 text-sm text-gray-700 flex-1"
                        >
                          {flexRender(
                            cell.column.columnDef.cell,
                            cell.getContext()
                          )}
                        </div>
                      ))}
                    </div>
                  );
                })}
              </div>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
  );
}

仮想化を使用することで、10万件のデータでもスムーズなスクロールを実現できます。overscanオプションで表示領域外の行を事前にレンダリングしておくことで、高速スクロール時のちらつきを軽減できます。

shadcn/uiとの統合例

shadcn/uiはTanStack TableをベースにしたDataTableコンポーネントを提供しています。洗練されたUIを素早く構築できます。

shadcn/uiのセットアップ

1
2
npx shadcn@latest init
npx shadcn@latest add table button input

shadcn/ui版DataTableの実装

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
// components/ui/data-table.tsx
import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
  getPaginationRowModel,
  getSortedRowModel,
  SortingState,
  getFilteredRowModel,
  ColumnFiltersState,
} from "@tanstack/react-table";
import { useState } from "react";

import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";

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

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

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    state: {
      sorting,
      columnFilters,
    },
  });

  return (
    <div className="space-y-4">
      {searchKey && (
        <Input
          placeholder="検索..."
          value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
          onChange={(e) =>
            table.getColumn(searchKey)?.setFilterValue(e.target.value)
          }
          className="max-w-sm"
        />
      )}
      <div className="rounded-md border">
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <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"
                >
                  データがありません
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>
      <div className="flex items-center justify-end space-x-2">
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          前へ
        </Button>
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          次へ
        </Button>
      </div>
    </div>
  );
}

ソート可能なヘッダーコンポーネント

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// components/ui/data-table-column-header.tsx
import { Column } from "@tanstack/react-table";
import { ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";

interface DataTableColumnHeaderProps<TData, TValue>
  extends React.HTMLAttributes<HTMLDivElement> {
  column: Column<TData, TValue>;
  title: string;
}

export function DataTableColumnHeader<TData, TValue>({
  column,
  title,
  className,
}: DataTableColumnHeaderProps<TData, TValue>) {
  if (!column.getCanSort()) {
    return <div className={cn(className)}>{title}</div>;
  }

  return (
    <Button
      variant="ghost"
      size="sm"
      className="-ml-3 h-8 data-[state=open]:bg-accent"
      onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
    >
      <span>{title}</span>
      {column.getIsSorted() === "desc" ? (
        <ArrowDown className="ml-2 h-4 w-4" />
      ) : column.getIsSorted() === "asc" ? (
        <ArrowUp className="ml-2 h-4 w-4" />
      ) : (
        <ArrowUpDown className="ml-2 h-4 w-4" />
      )}
    </Button>
  );
}

実務での活用パターン

サーバーサイドページネーション

大量データを扱う場合、クライアントサイドではなくサーバーサイドでページネーションを行うパターンが一般的です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import { useState, useEffect } from "react";
import { useReactTable, getCoreRowModel, PaginationState } from "@tanstack/react-table";
import { columns } from "./columns";
import { User } from "../../types/user";

type ServerPaginatedTableProps = {
  fetchData: (pagination: PaginationState) => Promise<{
    data: User[];
    totalCount: number;
  }>;
};

export function ServerPaginatedTable({ fetchData }: ServerPaginatedTableProps) {
  const [data, setData] = useState<User[]>([]);
  const [rowCount, setRowCount] = useState(0);
  const [pagination, setPagination] = useState<PaginationState>({
    pageIndex: 0,
    pageSize: 10,
  });
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const loadData = async () => {
      setIsLoading(true);
      try {
        const result = await fetchData(pagination);
        setData(result.data);
        setRowCount(result.totalCount);
      } finally {
        setIsLoading(false);
      }
    };
    loadData();
  }, [pagination, fetchData]);

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
    manualPagination: true, // サーバーサイドページネーションを有効化
    rowCount, // 総行数を渡す
    onPaginationChange: setPagination,
    state: {
      pagination,
    },
  });

  // ... レンダリング
}

manualPagination: trueを設定することで、TanStack Tableはクライアントサイドのページネーション処理をスキップし、渡されたデータをそのまま使用します。

TanStack Queryとの連携

TanStack QueryとTanStack Tableを組み合わせることで、サーバー状態の管理とテーブル機能を効率的に統合できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useReactTable, getCoreRowModel, PaginationState } from "@tanstack/react-table";
import { columns } from "./columns";

async function fetchUsers(pagination: PaginationState) {
  const response = await fetch(
    `/api/users?page=${pagination.pageIndex}&limit=${pagination.pageSize}`
  );
  return response.json();
}

export function QueryTable() {
  const [pagination, setPagination] = useState<PaginationState>({
    pageIndex: 0,
    pageSize: 10,
  });

  const { data, isLoading } = useQuery({
    queryKey: ["users", pagination],
    queryFn: () => fetchUsers(pagination),
  });

  const table = useReactTable({
    data: data?.users ?? [],
    columns,
    getCoreRowModel: getCoreRowModel(),
    manualPagination: true,
    rowCount: data?.totalCount ?? 0,
    onPaginationChange: setPagination,
    state: {
      pagination,
    },
  });

  if (isLoading) return <div>読み込み中...</div>;

  // ... レンダリング
}

行選択機能

チェックボックスによる行選択を実装するパターンです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import { useState } from "react";
import {
  useReactTable,
  getCoreRowModel,
  RowSelectionState,
  ColumnDef,
} from "@tanstack/react-table";
import { User } from "../../types/user";

// チェックボックス列を追加
const columnsWithSelection: ColumnDef<User>[] = [
  {
    id: "select",
    header: ({ table }) => (
      <input
        type="checkbox"
        checked={table.getIsAllRowsSelected()}
        onChange={table.getToggleAllRowsSelectedHandler()}
      />
    ),
    cell: ({ row }) => (
      <input
        type="checkbox"
        checked={row.getIsSelected()}
        onChange={row.getToggleSelectedHandler()}
      />
    ),
  },
  // ... 他の列定義
];

export function SelectableTable({ data }: { data: User[] }) {
  const [rowSelection, setRowSelection] = useState<RowSelectionState>({});

  const table = useReactTable({
    data,
    columns: columnsWithSelection,
    getCoreRowModel: getCoreRowModel(),
    onRowSelectionChange: setRowSelection,
    state: {
      rowSelection,
    },
  });

  // 選択された行のデータを取得
  const selectedRows = table.getSelectedRowModel().rows;
  console.log("選択された行:", selectedRows.map((row) => row.original));

  // ... レンダリング
}

期待される結果

本記事の実装を完了すると、以下の機能を持つデータテーブルが構築できます。

機能 動作確認項目
基本表示 データが正しく表形式で表示される
ソート 列ヘッダークリックで昇順/降順にソートされる
フィルター テキスト入力やセレクトボックスでデータが絞り込まれる
ページネーション ページ切り替えで表示データが変わる
仮想化 大量データ(1万件以上)でもスムーズにスクロールできる
行選択 チェックボックスで行を選択でき、選択状態を取得できる

TanStack TableはHeadless UIのため、UIの実装は自由にカスタマイズできます。Tailwind CSS、CSS-in-JS、Material UI、shadcn/uiなど、好みのスタイリング方法と組み合わせて使用できます。

まとめ

TanStack Tableは、Reactで高機能なデータテーブルを構築するための強力なHeadless UIライブラリです。ソート、フィルター、ページネーションといった基本機能から、仮想化による大量データ対応、サーバーサイド連携まで、幅広いユースケースに対応できます。

Headless UIアプローチにより、UIの完全な制御権を持ちながら、複雑なテーブルロジックをライブラリに任せることができます。shadcn/uiやTanStack Queryと組み合わせることで、モダンで保守性の高いデータテーブルを効率的に構築できます。

参考リンク