My shared table component was still on TanStack Table v8 when v9 changed how features and TypeScript generics work. I was inclined to upgrade, but “staying current” did not justify spreading a new generic through every column, row, and helper.
I wanted evidence that the new design would improve this component before accepting that migration work.
I used a coding agent to build both versions, propagate the repetitive type changes, and rerun the benchmark. That made the comparison cheap. It did not decide what counted as an improvement. As Simon Willison puts it, writing code is cheaper now, but establishing that it is good code still takes work.
What changed in v9
TanStack Table v8 exposed one broad table hook. Sorting, filtering, pagination, row selection, and other capabilities came through the default package shape whether a particular table used all of them or not.
V9 moves toward explicit feature trees. useReactTable becomes useTable, and the application registers the capabilities it needs through tableFeatures({ ... }) or uses stockFeatures for the complete set.
That is appealing for a shared component because the dependency can match the component’s actual behavior. It also moves more information into the type system. The main migration cost is propagating a new TFeatures generic through column definitions, helpers, rows, and exported component boundaries.
| Decision factor | V8 | V9 |
|---|---|---|
| Feature ownership | Broad default hook | Explicit feature tree |
| Bundle control | Features arrive together | Include only required features |
| Type surface | Fewer generic parameters | TFeatures crosses shared boundaries |
| Migration path | Existing API | Legacy bridge or direct migration |
Measuring the claim
I built the same BaseUI semantic table with TanStack Table v8.21.3 and v9.1.0. The benchmark mounted 100, 1,000, and 5,000 rows in a DOM environment through Vitest with the same Styletron providers.
| Dataset | V8 mount | V9 mount | Difference |
|---|---|---|---|
| 100 rows | 334.13 ms | 253.74 ms | 24.1% faster |
| 1,000 rows | 864.45 ms | 613.98 ms | 29.0% faster |
| 5,000 rows | 3,007.87 ms | 2,147.38 ms | 28.6% faster |
At 5,000 rows, the measured mount saved 860 ms of main-thread work. A minimal v9 feature tree also reduced the table library payload in this build from 38.0 kB to 14.1 kB minified, a 63% reduction.
Those are meaningful results for large client-rendered tables. They are not a promise that every product screen will become 29% faster. This is a controlled mount benchmark, not a field measurement. A small table whose time is dominated by data fetching, layout, or other components may show little visible difference. The benchmark answers the narrower question I actually had: does the v9 design create enough headroom to justify migrating the shared component? For this component, yes.
Where the migration work lives
In v8, a shared column definition might look like this:
const helper = createColumnHelper<Person>();
const columns: ColumnDef<Person>[] = [
helper.accessor('firstName', { header: 'First Name' }),
];
V9 makes the selected features part of those types:
const helper = createColumnHelper<StockFeatures, Person>();
const columns: ColumnDef<StockFeatures, Person>[] = [
helper.accessor('firstName', { header: 'First Name' }),
];
The change is simple in isolation and noisy across a library. If one helper or exported column array retains the old signature, TypeScript reports the disagreement much later through HeaderContext, footer rendering, or another nested generic. The reliable approach is to define the feature type once and carry it through every public table boundary.
V9 also changes how I express a partially selected checkbox. getIsSomeRowsSelected() now includes the all-selected case, so indeterminate state needs both predicates:
<Checkbox
checked={table.getIsAllRowsSelected()}
isIndeterminate={
table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()
}
onChange={() => table.toggleAllRowsSelected()}
/>
For a codebase with many tables, the legacy entry point provides a practical bridge:
import { useLegacyTable } from '@tanstack/react-table/legacy';
That allows the dependency to move first while individual components adopt feature trees over time. It is useful migration scaffolding, but I would not treat it as the destination: the explicit tree is where the bundle and ownership benefits come from.
The decision
I would upgrade the shared component, but base the decision on where it is used.
| Situation | Recommendation |
|---|---|
| Large tables or several optional features | Upgrade; the measured mount and bundle savings are material |
| Small table with no observed render problem | Upgrade for greenkeeping when convenient, not as urgent performance work |
| Many legacy table wrappers | Move the package first with the legacy bridge, then migrate by component |
| Unclear product benefit | Add a representative browser or mount benchmark before committing the migration |
For my shared component, the numbers settle the upgrade question. I would move the package first through the legacy bridge, then carry one explicit feature type through each table boundary. The extra generic is noisy, but it buys a smaller dependency and a large-table mount improvement I can measure.
One quick signal
Did this earn your time?
Thanks. That gives me something concrete to check.


