TanStack Tableでデータグリッドを構築する
TanStack Tableでデータグリッドを構築し、並び替え、フィルタリング、ページネーション、仮想化と、メモ化バグの対処法を解説します。
TanStack Tableはヘッドレスライブラリです。テーブルのロジック(ソート、フィルタリング、ページネーション、行モデル)はライブラリが担い、マークアップとスタイリングの100%はあなたが管理します。
既製グリッドのスタイルシートと格闘しながら、ボーダーを1ピクセル調整しようとした経験があれば、このトレードオフの意味はすぐに理解できるでしょう。実績あるステートエンジンと、DOMに対する白紙のキャンバスが手に入ります。デザインシステムが自社のものである場合、まさに求めていたものです。
本ガイドでは、現在の安定版リリース @tanstack/react-table v8 を使って実際のデータグリッドを構築します。ソート、グローバルフィルタリング、ページネーションを備えた単一のテーブルです。さらに、本番環境で実際に問題となる2つのスケーリング課題、すなわち無限再レンダリングを引き起こすメモ化のバグと、数千行に対応するための仮想化についても取り上げます。
重要なポイント
- TanStack Table v8では、すべてのヘッダーとセルを
flexRenderでレンダリングします。ヘッダーにはflexRender(header.column.columnDef.header, header.getContext())、セルにはflexRender(cell.column.columnDef.cell, cell.getContext())を使用します。v7時代のcell.render('Cell')やcolumn.render('Header')の呼び出しはもはや存在しません。 - ソート、フィルタリング、ページネーションはオプトイン方式の行モデルです。
getSortedRowModel()、getFilteredRowModel()、getPaginationRowModel()を1つのuseReactTable呼び出しに渡せば、順序は問いません。 - TanStack Tableで最もよく見られるバグは、毎回のレンダリングで新しい
dataまたはcolumns配列を渡すことによる無限再レンダリングループです。両方をuseMemoでラップするか、モジュールスコープで定義してください。 - クライアントサイドで数千行を超えたら、ページネーションをやめて
@tanstack/react-virtualのuseVirtualizerを使い、table.getRowModel().rowsに対して仮想化を行いましょう。 - 安定版はv8で、npmの
latestタグでは現在8.21.3です。v9は2026年6月にベータ版に入ったため、本番環境のテーブルはv8に留めてください。
「ヘッドレス」の意味とその重要性
ヘッドレスとは、ライブラリがUIではなく振る舞いを提供することを意味します。TanStack Tableは行モデルを計算し、状態とハンドラーを公開しますが、DOMには何も出力しません。v8ではデフォルトスタイルとrole属性の提供を廃止し、コアをフレームワーク非依存に保てるようにしました。メリットは完全なコントロールです。責任として、セマンティクスもあなたが担うことになります。<table>、<th scope>、そしてARIAロールはすべてあなたの責任です。
実際のところ、スタイリングは持ち込み方式です。Tailwindのユーティリティクラス、CSS Modules、あるいは自前の<table>マークアップ上のstyled componentsを使えます。ライブラリがそれらに触れることは一切ありません。
基本的なTanStack Tableグリッドのセットアップ
Discover how at OpenReplay.com.
パッケージをインストールし、useReactTableフックでテーブルを構築します。型推論のためにcreateColumnHelperでカラムを定義し、dataとcolumnsを渡し、データを行にマッピングするベース行モデルであるgetCoreRowModel()を登録します。
npm install @tanstack/react-table
このアダプターはReact 16.8から19までのすべてのバージョンをサポートしていますが、React 19とともに提供されるReact Compilerの下では正常に動作しない可能性があります。
import {
createColumnHelper,
useReactTable,
getCoreRowModel,
flexRender,
} from '@tanstack/react-table'
type User = { firstName: string; lastName: string; age: number }
const columnHelper = createColumnHelper<User>()
const columns = [
columnHelper.accessor('firstName', { header: 'First Name' }),
columnHelper.accessor('lastName', { header: 'Last Name' }),
columnHelper.accessor('age', { header: 'Age' }),
]
getHeaderGroups()、getRowModel().rows、そして各行のgetVisibleCells()を走査してレンダリングします。文字列ヘッダー、JSX、コンポーネントセルがすべて正しく解決されるよう、カラム定義とそのコンテキストをflexRenderに渡します。v8移行ガイドによると、flexRenderは削除されたcell.render('Cell') / column.render('Header')の呼び出しを置き換えるものです。
function DataGrid({ data }: { data: User[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<table>
<thead>
{table.getHeaderGroups().map(hg => (
<tr key={hg.id}>
{hg.headers.map(header => (
<th key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}
ソートの追加
getSortedRowModel()を追加し、onSortingChangeでsorting状態を保持し、各ヘッダーをheader.column.getToggleSortingHandler()に紐付けることでソートを有効にします。header.column.getIsSorted()('asc'、'desc'、またはfalseを返します)を使って方向インジケーターを表示します。
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
<th onClick={header.column.getToggleSortingHandler()} style={{ cursor: 'pointer' }}>
{flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? ''}
</th>
フィルタリングとグローバル検索
グローバル検索は1つの状態で管理します。getFilteredRowModel()を追加し、stateにglobalFilter文字列を保持し、onGlobalFilterChangeで更新します。デフォルトのグローバルフィルターは、すべてのカラムにわたって部分文字列を照合します。
const [globalFilter, setGlobalFilter] = useState('')
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})
// <input value={globalFilter} onChange={e => setGlobalFilter(e.target.value)} />
カラムごとのフィルターは同じ行モデルを使用し、ヘッダーの入力からcolumn.getFilterValue()とcolumn.setFilterValue()によって駆動されます。ファジーマッチングも利用可能ですが、カスタムfilterFnとして別途@tanstack/match-sorter-utilsヘルパーが必要です。部分文字列マッチングでは不十分な場合にのみ使用してください。
ページネーション
getPaginationRowModel()を追加し、pageIndex/pageSizeの状態で制御します。次に、table.getCanNextPage()とtable.getCanPreviousPage()でNext/Previousボタンの有効・無効を切り替えます。v8では行モデルの登録順序は関係ないため、同じフック呼び出し内でソートやフィルタリングと自由に組み合わせられます。
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 })
const table = useReactTable({
data,
columns,
state: { sorting, globalFilter, pagination },
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
Previous
</button>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
Next
</button>
これで完全に動作するグリッドの完成です。ソート、フィルタリング、ページネーションをすべて1つのuseReactTable呼び出しで実現しています。
スケーリング:メモ化の落とし穴、仮想化、カスタムセル
TanStack Tableで最もよく見られるバグは、毎回のレンダリングで新しいdataまたはcolumns配列を渡すことによる無限再レンダリングループです。コンポーネント本体内でconst columns = [...]やconst data = [...]を宣言すると、毎回新しい参照が生成されます。TanStack Tableはその参照の変化を検知して行モデルを再計算し、再レンダリングをトリガーするため、サイクルが繰り返されます。両方をuseMemoでラップするか(あるいは先ほどの例のようにモジュールスコープで定義するか)して、参照を安定させてください。
const columns = useMemo(() => [/* ... */], [])
const data = useMemo(() => fetchedRows, [fetchedRows])
ページネーションか仮想化か? ページサイズが限定されている場合や、サーバーからページ単位でデータを取得する場合はページネーションを使いましょう。一方、数千行のクライアントサイドデータを1つの連続スクロールビューで保持する場合は仮想化を使います。後者の場合は、@tanstack/react-virtualのuseVirtualizerフックを使い、table.getRowModel().rowsに対して表示中の行だけをレンダリングします。なお、現在のv3フックはuseVirtualizerであり、旧来のuseVirtualではありません。
import { useVirtualizer } from '@tanstack/react-virtual'
const rows = table.getRowModel().rows
const parentRef = useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 10,
})
その後、TanStack Virtualのドキュメントに示されているように、getTotalSize()でサイズを指定したスペーサーの内側にrowVirtualizer.getVirtualItems()をレンダリングします。これはセッションリプレイが露わにするのが得意な障害モードです。仮想化されていない1万行のグリッドはローカルのシードデータでは問題なく動作しますが、本番環境でユーザーがソートや高速スクロールを行う様子をリプレイすると、フレームドロップやスクロールのジャンクが表面化します。
カスタムセルはカラムのcellレンダラーそのものです。ステータスバッジ、フォーマットされた日付、または編集可能な入力フィールドを返せば、ライブラリはUIを自分でレンダリングしないため、邪魔になることはありません。カラムのピン留め、リサイズ、並べ替えも、それぞれ専用の状態APIを通じてサポートされています。
まとめ
これで、ソート、フィルタリング、ページネーションを1つのuseReactTable呼び出しで実現するグリッドが完成しました。さらに、高速化のための2つの手法も習得しました。data/columnsの参照を安定させることと、数千行を超えた場合の仮想化です。v9は2026年6月からベータ段階にあるため、今日はv8で構築し、行数がページに収まりきらなくなった時点でuseVirtualizerを採用してください。
よくある質問
TanStack Tableが無限に再レンダリングされるのはなぜですか?
無限再レンダリングループは、ほぼ必ずと言っていいほど、毎回のレンダリングで新しいdataまたはcolumns配列を渡すことが原因です。コンポーネント本体内でconst columns = []やconst data = []を宣言すると、毎回新しい参照が生成されます。そのためTanStack Tableは参照の変化を検知して行モデルを再計算し、さらに別のレンダリングをトリガーします。両方をuseMemoでラップするか、モジュールスコープで定義して参照を安定させてください。
TanStack Table v8とv9のどちらを使うべきですか?
本番環境ではv8を使用してください。安定版はv8(npmのlatestタグでは8.21.3)であり、v9は2026年6月からベータ段階にあり、npmのbetaタグ下でプレリリースビルドとしてのみ提供されています。v8はReact 16.8、17、18、19で動作します。安定版がリリースされるまでは、本番環境のテーブルはv8に留め、重要でないコードでのみv9を試してください。
TanStack Tableでページネーションと仮想化はどう使い分けるべきですか?
ページサイズが限定されている場合や、サーバーからページ単位でデータを取得する場合は、getPaginationRowModelとpageIndex/pageSizeの状態を使ってページネーションを行います。数千行のクライアントサイドデータを1つの連続スクロールビューで保持する場合は、@tanstack/react-virtualのuseVirtualizerをtable.getRowModel().rowsに対して使い、表示中の行だけをレンダリングする仮想化を採用します。両者は相互排他的ではありませんが、単一スクロールエリアにクライアントサイドで数千行を超えるデータがある場合は、仮想化が正しい選択です。
TanStack Tableはデフォルトのスタイリングやアクセシビリティロールを提供しますか?
いいえ。TanStack Table v8は完全にヘッドレスです。マークアップ、スタイリング、role属性のいずれも出力しません。v8の書き直しでは、コアがあらゆるフレームワークで動作できるよう、これらすべてを廃止しました。DOM全体はあなたが管理するため、tableやscope付きのth、ARIAロールなどのセマンティックマークアップはあなたの責任です。自前のマークアップにTailwind、CSS Modules、またはstyled componentsでスタイルを適用してください。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k