KahWee Teng — Software Engineer

Field notes from KahWee Teng on AI-assisted coding, web systems, self-hosting, and the tools he keeps using.

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.