Data note
26

Data note · Published Aug 8, 2026 · Updated Aug 20, 2026

Evaluating a TanStack Table v9 Upgrade

I wanted to keep a shared table component current without accepting migration work on faith. A side-by-side benchmark made the trade-off much easier to judge.

A field-guide drawing of a California black bear
In this article4 sections

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 factorV8V9
Feature ownershipBroad default hookExplicit feature tree
Bundle controlFeatures arrive togetherInclude only required features
Type surfaceFewer generic parametersTFeatures crosses shared boundaries
Migration pathExisting APILegacy 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.

DatasetV8 mountV9 mountDifference
100 rows334.13 ms253.74 ms24.1% faster
1,000 rows864.45 ms613.98 ms29.0% faster
5,000 rows3,007.87 ms2,147.38 ms28.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.

SituationRecommendation
Large tables or several optional featuresUpgrade; the measured mount and bundle savings are material
Small table with no observed render problemUpgrade for greenkeeping when convenient, not as urgent performance work
Many legacy table wrappersMove the package first with the legacy bridge, then migrate by component
Unclear product benefitAdd 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?

What was missing?

Thanks. That gives me something concrete to check.