TanStack Table v9 Trades Monoliths for Explicit Feature Trees
TanStack Table v8 bundled every feature into a single hook. Sorting, filtering, pagination, and row selection sat in the default core package regardless of whether a table used them.
V9 breaks that monolith. useReactTable becomes useTable, and capabilities require explicit feature registrations via tableFeatures({ ... }) or stockFeatures.
The migration requires updating generic signatures and row selection logic, but the performance payoff on large datasets is immediate.
5,000 Rows: The Benchmark
I upgraded a BaseUI semantic table component from v8.21.3 to v9.1.0 and ran a DOM mounting benchmark across 100, 1,000, and 5,000 rows using Vitest and Styletron providers.
| Dataset Size | v8.21.3 Mount Time | v9.1.0 Mount Time | Speedup | Main-Thread Saved |
|---|---|---|---|---|
| 100 rows | 334.13 ms | 253.74 ms | 24.1% | -80.39 ms |
| 1,000 rows | 864.45 ms | 613.98 ms | 29.0% | -250.47 ms |
| 5,000 rows | 3,007.87 ms | 2,147.38 ms | 28.6% | -860.49 ms |
At 5,000 rows, initial rendering time dropped from 3.01 seconds down to 2.15 seconds. Removing internal object spreading inside row model loops saves nearly a full second of main-thread blocking time during component mount.
The Type Generic Cascade
The hardest part of the migration is not runtime logic. It is propagating the TFeatures generic parameter across TypeScript boundaries.
In v8, ColumnDef<TData, TValue> accepted two type arguments. Creating a column helper required createColumnHelper<TData>().
In v9, TFeatures becomes the first generic parameter across core interfaces: ColumnDef<TFeatures, TData, TValue>, Row<TFeatures, TData>, and createColumnHelper<TFeatures, TData>().
// Before (v8)
import { createColumnHelper, ColumnDef } from '@tanstack/react-table';
const helper = createColumnHelper<Person>();
const columns: ColumnDef<Person>[] = [
helper.accessor('firstName', { header: 'First Name' }),
];
If you leave column helpers or column definition types parameterless or defined against v8 signatures, TypeScript throws multi-page type mismatch errors in HeaderContext and footer rendering.
// After (v9)
import { createColumnHelper, StockFeatures, ColumnDef } from '@tanstack/react-table';
const helper = createColumnHelper<StockFeatures, Person>();
const columns: ColumnDef<StockFeatures, Person>[] = [
helper.accessor('firstName', { header: 'First Name' }),
];
Explicitly passing StockFeatures (or your table's specific typeof dataTableFeatures) to column helpers and exported column arrays satisfies the generic boundary cleanly.
Tree-Shaking Minimal Feature Trees
In v8, importing useReactTable loaded all features (resizing, grouping, pinning, expansion). In v9, defining explicit tableFeatures reduces the core library payload from 38.0 KB down to 14.1 KB minified (a 63% savings).
// Minimal feature tree (14.1 KB minified)
import {
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
createSortedRowModel,
createFilteredRowModel,
} from '@tanstack/react-table';
export const minimalFeatures = tableFeatures({
rowSortingFeature,
columnFilteringFeature,
sortedRowModel: createSortedRowModel(),
filteredRowModel: createFilteredRowModel(),
});
Sub-Component State Subscriptions
V8 forced the parent table container to re-render whenever internal state (rowSelection, sorting) changed. V9 introduces @tanstack/store atoms, allowing individual sub-components to subscribe directly to state slices.
// Opt a single counter component into selection state without re-rendering the table
<table.Subscribe source={table.atoms.rowSelection}>
{(rowSelection) => (
<Block marginTop="16px">
Selected {Object.keys(rowSelection).length} rows
</Block>
)}
</table.Subscribe>
The Incremental Migration Bridge
For large codebases with dozens of legacy tables, refactoring every column helper in one PR is unrealistic. V9 provides an opt-in migration bridge that emulates v8 signatures:
// Incremental bridge for legacy tables
import { useLegacyTable } from '@tanstack/react-table/legacy';
const table = useLegacyTable({ data, columns, getCoreRowModel: getCoreRowModel() });
This lets teams upgrade the @tanstack/react-table package dependency immediately while migrating individual table components incrementally.
Row Selection Predicate Shift
V8's getIsSomeRowsSelected() returned true only when a subset of rows was selected. It excluded the case where all rows were selected, making it a direct mapping for <Checkbox isIndeterminate={table.getIsSomeRowsSelected()} />.
In v9, getIsSomeRowsSelected() means at least one row is selected (including all-selected). Indeterminate checkboxes must explicitly combine both predicates:
<Checkbox
checked={table.getIsAllRowsSelected()}
isIndeterminate={table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()}
onChange={() => table.toggleAllRowsSelected()}
/>
The Verdict
TanStack Table v9 trades a single monolithic hook for explicit feature trees. The extra type ceremony with TFeatures requires updating shared column helpers, but a 28% reduction in initial rendering time and 63% smaller core bundle make the upgrade well worth the effort.