web-ui-tanstack-table について
TanStack Table v8で型付きのヘッドレステーブルを構築するための実践ガイドです。行モデルやテーブル状態、クライアント側とサーバー側の処理を扱い、マークアップやスタイルは規定しません。
主な機能
- 01型付きカラム定義
- 02ソート、フィルター、ページネーション
- 03行選択と展開
- 04カラムの表示切り替えと固定
- 05サーバー側データのパターン
- 06安定した参照と行 ID
活用シーン
- 01独自のテーブルマークアップの構築
- 02テーブル状態とサーバークエリの連携
- 03選択と一括操作の追加
- 04大規模テーブルの応答性維持
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ agents-inc/ skills --skill web-ui-tanstack-tableインストール後、Claude Code で/web-ui-tanstack-tableを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
metadata.yamlreference.mdexamples/column-pinning.mdcolumn-resizing.mdcolumn-visibility.mdcore.mdexpanding.mdfiltering.mdpagination.mdselection.mdserver-side.mdsorting.mdvirtualization.md詳細
TanStack Table のパターン
クイックガイド: TanStack Table v8 はテーブルの ロジック を担います。行、セル、ヘッダーグループを計算しますが、描画は一切行いません。
createColumnHelper<T>()でデータを定義し、各機能の行モデルをインポートして有効にします。インスタンスが返す値を使って、好きなマークアップを描画できます。問題の多くは、次の 2 点に起因します。dataとcolumnsは安定した参照でなければならず、そうでないとテーブルが再計算を繰り返します。また、サーバー側ですでに処理済みであることを示すのはmanual*フラグです。
詳細なリソース:
- examples/core.md — インスタンスの設定、列定義、
flexRender - examples/sorting.md — ソート関数、複数列のソート、
aria-sort - examples/filtering.md — 列フィルター、グローバルフィルター、カスタム
filterFn - examples/pagination.md — ページ状態とナビゲーションコントロール
- examples/selection.md — チェックボックス列、中間状態、一括操作
- examples/expanding.md — 展開可能な行とサブ行
- examples/column-visibility.md — 列の表示と非表示
- examples/column-pinning.md — ピン留めされた列とそのスティッキーオフセット
- examples/column-resizing.md — サイズ変更ハンドルと CSS 変数を使う手法
- examples/server-side.md —
manual*フラグとrowCount - examples/virtualization.md — 行モデルをウィンドウ化リストにつなぐ方法
- reference.md — インポート、機能ごとのチェックリスト、コード付きのアンチパターン
該当する方法を選ぶ
ソート、フィルタリング、ページングをどこで行うかによって、そのほかの答えがすべて変わります。
- クライアントがすべての行を保持します。 必要な機能の行モデル(
getSortedRowModel、getFilteredRowModel、getPaginationRowModel)をインポートすると、テーブルが処理します。examples/core.md と機能ごとのファイルを参照してください。 - サーバー側ですでに処理済みです。
manualSorting、manualFiltering、manualPaginationをtrueに設定してrowCountを指定し、これらの行モデルはインポートしません。manual*フラグを設定すると、対応する行モデルは無視されます。そのため両方を含めるのは無駄で、機能が動作しているように見えてしまいます。examples/server-side.md を参照してください。 - すべての行がそろっていますが、描画するには多すぎます。 クライアント側の行モデルを維持し、ページングではなく出力をウィンドウ化します。examples/virtualization.md を参照してください。
<critical_requirements>
TanStack Table のコードを書く前に
data と columns の参照を安定させてください — useMemo を使うか、モジュールレベルの定数にします。テーブルは同一性で比較するため、レンダーのたびに新しい配列を作ると、レンダーのたびに新しい列セットになります。その結果、エラーではなくタブが応答しなくなるような更新ループが発生します。
列ヘルパーに型を指定してください:createColumnHelper<Row>()。 これにより、各アクセサーキーが行の型に照らしてチェックされ、各セルの getValue() にも読み取るフィールドの型が付与されます。フィールド名の変更が、undefined の列ではなくコンパイルエラーになります。
accessorFn には明示的な id を設定してください。 文字列の accessorKey には独自の ID が設定されますが、関数には ID を導き出す名前がありません。ID がなければ、ソート、フィルタリング、表示設定でその列を指定できません。
行を選択する場合やデータを再読み込みする場合は、getRowId を設定してください。 デフォルトの識別子は配列のインデックスです。そのため、ソート、フィルター、再取得を行うと、選択状態がその位置に新たに移動してきた行へ、気付かないうちに移ります。
使用する行モデルだけをインポートしてください。 それぞれが独立したエントリーポイントであり、使わないものはツリーシェイキングで除外されます。このようなパッケージ構成になっている理由がまさにここにあります。
flexRender でレンダリングします。 header と cell はそれぞれ文字列またはコンポーネントにできます。どちらにも対応するのが flexRender です。columnDef.header を直接読み取る方法は、関数を指定する最初の列が現れるまでは機能します。
</critical_requirements>
自動検出: @tanstack/react-table, useReactTable, createColumnHelper, columnHelper.accessor, columnHelper.display, columnHelper.group, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, flexRender, ColumnDef, SortingState, ColumnFiltersState, PaginationState, RowSelectionState, VisibilityState, ExpandedState, ColumnPinningState, manualPagination, manualSorting, accessorKey, accessorFn, getRowId
対象:
- 列の定義 — アクセサー、計算値、表示列、グループ化されたヘッダー
- テーブルの状態:ソート、フィルタリング、ページネーション、選択、展開、表示設定、ピン留め、サイズ設定
- クライアント側とサーバー側のどちらで処理するかの選択と、後者の接続
- ヘッダーグループ、表示中のセル、行モデルを使った、レンダリングのためのインスタンスの読み取り
- メモ化、ツリーシェイキング、行出力のウィンドウ化による、大規模なテーブルの応答性の維持
別途扱う項目:
- すべての要素とスタイル。このライブラリが返すのはデータ構造です。例にある
<table>はそれらをレンダリングする方法のひとつであり、結果の見た目はこのスキルの対象外です - 行の取得元。サーバー側モードでは、どのような手段で取得したかにかかわらず、すでにページネーションされた配列と合計件数を受け取ります
- フィルターやページネーションの入力に使うコントロール。テーブルが必要とするのは値と変更ハンドラーだけで、それらを生成するウィジェットの詳細は問いません
- ウィンドウ化そのもの。テーブルから渡されるのはフラットな行配列です。ビューポートを測定して、収まる範囲をレンダリングする機能は別の機能であり、examples/virtualization.md では両者をつなぐ方法のみを示します
ヘッドレスとは、ライブラリの仕様にこちらが合わせる必要がないということです。上書きするテーマも、対処しなければならないマークアップも、内部にアクセスする必要のあるコンポーネントもありません。インスタンスに質問し、何を描画するかは自分で決められます。
その代わり、デフォルトで有効になっている機能はありません。 ソートされないテーブルは壊れているのではなく、getSortedRowModel をインポートしていないだけです。この点は、機能を切り替えるための props が用意されているコンポーネントライブラリから移ってきた人がつまずきやすいところです。覚えておくとよいでしょう。機能が動作しないときは、まず行モデルが設定されているかを確認し、次に manual* フラグによってテーブルが処理を行わないよう指定されていないかを確認します。
同じ設計により、ライブラリはフレームワークに依存しません。コアはプレーンな TypeScript で、React アダプターは薄い層です。そのため、API は処理を行うフックではなく、データを返す関数として構成されています。
<decision_framework>
インポートする行モデル
| 機能 | 行モデル | 備考 |
|---|---|---|
| すべてのケース | getCoreRowModel | 常に必須 |
| ソート | getSortedRowModel | manualSorting を設定する場合は省略 |
| 列およびグローバルフィルタリング | getFilteredRowModel | manualFiltering を設定する場合は省略 |
| ページ分割 | getPaginationRowModel | manualPagination を設定する場合は省略 |
| 展開可能な行、サブ行 | getExpandedRowModel | |
| 行のグループ化 | getGroupedRowModel | |
| フィルター UI 向けの個別値 | getFacetedRowModel | |
| 列のピン留め、列のサイズ設定 | なし | どちらもコアに含まれています |
列の宣言方法
Reading a field directly? → columnHelper.accessor("email")
Reading a nested field? → columnHelper.accessor("address.city") (dot notation)
Deriving a value from the row? → columnHelper.accessor(fn, { id: "…" }) (id is required)
No data at all — buttons, a checkbox?
→ columnHelper.display({ id: "…" })
Heading a set of other columns? → columnHelper.group({ columns: [...] })
重要な違いは、アクセサーの戻り値がソートとフィルタリングの対象になるため、プリミティブ値のままにすることです。表示に関するものはすべて cell に記述します。
</decision_framework>
基本パターン
パターン 1:テーブルのセットアップ
インスタンスは、データ、列、および選択した行モデルから構築されます。どちらの入力もメモ化されます。getRowId を使うと、インデックスベースの識別子を安定した識別子に置き換えられます。
const columnHelper = createColumnHelper<User>();
const columns = useMemo(
() => [
columnHelper.accessor("firstName", { header: "First Name" }),
columnHelper.accessor((row) => `${row.firstName} ${row.lastName}`, {
id: "fullName", // required — a function has no key to derive one from
header: "Full Name",
}),
],
[],
);
const table = useReactTable({
data: useMemo(() => users, [users]),
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) => row.id,
});
コード全文:examples/core.md
パターン 2:ソート
状態を親に引き上げると永続化できます。並べ替えは行モデルが行います。
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
/* … */
state: { sorting },
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
});
columnHelper.accessor("createdAt", { sortingFn: "datetime" });
Date オブジェクトには sortingFn: "datetime" が必要です。デフォルトの比較関数はこれらを文字列として扱うため、曜日名で並べ替えられます。
コード全文:examples/sorting.md
パターン 3:フィルタリング
列フィルタとグローバルフィルタは、それぞれ別の状態とハンドラーを持ち、どちらも同じ行モデルによって処理されます。
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const table = useReactTable({
/* … */
state: { columnFilters, globalFilter },
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: setGlobalFilter,
getFilteredRowModel: getFilteredRowModel(),
});
複数の列フィルタは積集合になります。つまり、行はすべてのフィルタを満たす必要があります。組み込みの和集合はありません。和集合を実現するには、グローバルフィルタかカスタムの filterFn を使用します。
コード全文:examples/filtering.md
パターン 4:ページネーション
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const table = useReactTable({
/* … */
state: { pagination },
onPaginationChange: setPagination,
getPaginationRowModel: getPaginationRowModel(),
});
pageIndex はゼロから数えます。多くの API は 1 から数えるため、変換はリクエストの境界でのみ行います。
コード全文:examples/pagination.md
パターン 5:行の選択
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const table = useReactTable({
/* … */
state: { rowSelection },
onRowSelectionChange: setRowSelection,
enableRowSelection: true,
getRowId: (row) => row.id, // the selection is keyed by this
});
選択状態は行 ID をキーとするマップです。そのため、getRowId がないと配列内の位置がキーになります。テーブルをソートすると、各選択状態が別のレコードに割り当てられてしまいます。
コード全文:examples/selection.md
パターン 6:サーバーサイドデータ
manual* フラグは、渡された配列がすでにソート、フィルタリング、ページ分割されていることをテーブルに伝えます。その場合、テーブルは状態の変化を通知するだけで、計算は行いません。
const table = useReactTable({
data: apiData ?? [],
columns,
state: { pagination, sorting, columnFilters },
onPaginationChange: setPagination,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
manualFiltering: true,
rowCount: totalFromApi, // it cannot count rows it never received
});
コード全文:examples/server-side.md
パターン 7:列の表示切り替え
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({
email: false, // hidden until asked for
});
columnHelper.accessor("id", { enableHiding: false }); // never offered in the toggle UI
非表示の列は getVisibleCells() から除外されますが、テーブル内には残るため、その列のフィルタやソートは引き続き適用されます。
コード全文:examples/column-visibility.md
パターン 8:行の展開
const [expanded, setExpanded] = useState<ExpandedState>({});
const table = useReactTable({
/* … */
state: { expanded },
onExpandedChange: setExpanded,
getExpandedRowModel: getExpandedRowModel(),
getRowCanExpand: () => true, // or a predicate over row.original
});
展開された行はネストされず、行モデル内に挿入されます。そのため、詳細パネルを描画するには、row.getIsExpanded() を確認して自分で追加の要素を出力します。
コード全文:examples/expanding.md
パターン 9:再利用可能なジェネリックテーブル
ジェネリクスを使うと、呼び出し側での型推論を損なうことなく、1 つのコンポーネントをあらゆる行の型に使用できます。
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
}
export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
// render from table.getHeaderGroups() and table.getRowModel()
}
コード全文:examples/core.md
パターン 10:列の固定
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
left: ["id"],
right: ["actions"],
});
固定すると列の順序が変わり、各列がどちら側にあるかが通知されます。位置指定は適用されません。position: sticky、オフセット、不透明な背景は自分で設定する必要があります。スクロール中のコンテンツが透けて見えないようにするのが、この背景の役割です。
コード全文:examples/column-pinning.md
パターン 11:列の幅変更
const table = useReactTable({
/* … */
enableColumnResizing: true,
columnResizeMode: "onChange", // or "onEnd"
});
"onChange" はドラッグ中に更新され、そのたびにすべてのセルが再レンダリングされるため、フレームレートを維持するには CSS 変数を使う方法と、メモ化したボディが必要です。"onEnd" はドラッグを離したときに確定するため、どちらも必要ありません。
コード全文:examples/column-resizing.md
<red_flags>
注意すべき問題
実行時に動作しなくなるもの:
columnsまたはdataをインラインで構築する — レンダリングのたびに新しい配列 ID が生成され、タブが応答しなくなるまでテーブルがループ更新されますidのないaccessorFn— カラムに識別子がないため、インスタンスの構築中に例外が発生しますmanual*フラグとクライアント側の行モデルを併用する — フラグが優先されるため、機能が接続されているように見えても行モデルは機能しませんrowCountまたはpageCountのないmanualPagination— テーブルは単一ページからページ数を算出できないため、ページ移動が最初のページで止まりますgetRowIdを指定しない選択 — 配列インデックスをキーとして使うため、並べ替えや再取得によって選択対象が別のレコードに移ります- アクセサーから JSX を返す — 並べ替えとフィルタリングで値ではなく React 要素が比較されるため、カラムが意味のある基準で並び替えられません
flexRenderを使わずにcolumnDef.headerまたはcolumnDef.cellを直接読み取る — 文字列のヘッダーでは動作しますが、それ以外のカラムではすべて関数がそのまま表示されます
予想外の動作:
pageIndexはゼロ始まりですが、多くの API は 1 始まりですautoResetPageIndexの既定値はtrueで、データが変更されるたびに最初のページへ戻ります — 新しいデータの到着自体がページ変更となるサーバーサイドモードでは、通常望ましくありません- カラムフィルターは AND 条件で組み合わされます。組み込みの OR 条件はありません
- カラムの固定とサイズ変更には、他のすべての機能と異なり、行モデルは必要ありません
- 固定はカラムの並べ替えとグループ化より先に行われるため、両者が競合する場合は固定が優先されます
- サイズ変更ハンドルには
getResizeHandler()のonMouseDownとonTouchStartの両方が必要です。そうしないと、タッチデバイスでは機能しません - 非表示のカラムもフィルタリングと並べ替えの対象です。表示設定が影響するのは
getVisibleCells()のみで、それより上流には影響しません