DataTable TanStack Table v9 migration guide
@ngrok/mantle/data-table now re-exports TanStack Table v9. The DataTable parts keep their names and props. What changes is how you build the table instance and its columns: v9 makes every feature opt-in, so each table registers the features it uses, and TypeScript knows which methods exist on that table.
TL;DR: for each table that calls useReactTable:
- Build a
featuresobject withtableFeatures({ … })at module scope and calluseTable({ features, data, columns }). - Delete
getCoreRowModel. Turn every otherget*RowModel()option into a feature plus a row-model slot insidetableFeatures. Register thefilterFnsandaggregationFnsyour columns resolve by name. - Type the helper as
createColumnHelper<typeof features, T>()and wrap the array incolumnHelper.columns([…]). - Rename
sortingFntosortFnand register the comparators you use in thesortFnsslot. - Read
table.stateinstead oftable.getState(). - Type
table.options.metathrough thetableMetaslot instead of anascast.
The rest of this page covers each step with before/after code, the mantle parts that now need a feature, and a checklist.
Codemod
From a mantle checkout:
In a project that installs @ngrok/mantle:
The rules run on .ts and .tsx files that import from @ngrok/mantle/data-table, and leave every other file alone. They apply the rename in step 4, sortingFn to sortFn, in a column definition only: the options of a columnHelper call, an element of a columns array, or an object with a column key such as header or cell. Row data with a sortingFn field stays. They apply the read in step 5, table.getState() to table.state, on three shapes: the result of a useTable call under any name, a callback's table argument, and a prop whose name ends in table. The rules read the useTable result in the scope that declares it, so a sibling function's variable under the same name stays, and so does a parameter that shadows it in a nested function. The table suffix is a spelling heuristic, so read the dry run for a store that shares the suffix. A store the file declares under that name stays. Steps 1 to 3 and 6 stay manual, because which features and row models a table registers depends on what its columns do. Pass --dry-run to print the rewrites first, and run your formatter after the rewrite.
Why this change
TanStack Table v8 shipped every feature on every table. Sorting, filtering, pinning, and selection code landed in your bundle whether the table used them or not, and table.options.meta was untyped unless you merged a global declaration.
v9 flips that. A table registers the features it uses in a features object, and that object's type flows into the table, its rows, columns, and cells:
- Only the registered features ship in the bundle.
row.getIsSelected()exists on the row type only when the table registersrowSelectionFeature. A call on a table that can't select is a compile error, not a runtimeundefined is not a function.table.options.metaandcolumnDef.metatake their types from the features object, with no global declaration merging and noascasts.
Mantle's DataTable parts are generic over that features object. DataTable.HeaderSortButton rejects a column from a table without rowSortingFeature, and DataTable.RowExpandButton rejects a row from a table without rowExpandingFeature, both at compile time. No other mantle prop name changed.
Before you start
@tanstack/react-tablemust not be a direct dependency of your app.@ngrok/mantle/data-tablere-exports every v9 export:useTable,tableFeatures,createColumnHelper, every*Featureobject, everycreate*RowModelfactory, thesortFn_*,filterFn_*, andaggregationFn_*functions,metaHelper,flexRender, and every type. Ifpackage.jsonlists@tanstack/react-table, remove it and point every import at@ngrok/mantle/data-table.- Do not reach for the compatibility layer. Mantle does not re-export
@tanstack/react-table/legacy,useLegacyTable,legacyCreateColumnHelper, or the deprecatedget*RowModelstubs. See What not to do. - Read the DataTable docs for the current recipes. Every example there is already on v9.
What to search for
Every file that imports from @ngrok/mantle/data-table is a candidate. Run this from your app root to find the renamed calls and options:
The grep covers the renames. The table below adds the type, state, and method names to check by hand in each candidate file, and maps each to a step:
Step 1: build a features object
useReactTable is now useTable, and it requires a features option. Build the object with tableFeatures, which infers the type that every other v9 generic reads.
Before:
After:
A table with no sorting, filtering, expansion, or selection registers nothing:
Where to define features
Define the features object at module scope, next to the column helper. useTable registers the features when it builds the table on the first render, and every later options merge keeps that first features object. A features object built inside the component is re-created each render but never applied again, so a conditional feature set has no effect after mount.
Most apps have a handful of table shapes. Put those in one module and import the preset each table needs:
A table that needs its own tableMeta type (see Step 7) spreads a preset into its own tableFeatures call:
Two other shapes exist. Neither is the default:
createTableHook({ features })returns auseAppTablehook, acreateAppColumnHelperfactory, anduseTableContext,useCellContext, anduseHeaderContexthooks, all bound to one features object. The tableuseAppTablereturns carries context-boundAppTable,AppCell,AppHeader, andAppFootercomponents. Reach for it when an app wants one table hook with shared cell and header components. TheuseAppTablereturn value is a validtableprop forDataTable.Root.stockFeaturesregisters every built-in feature at once:tableFeatures({ ...stockFeatures, sortedRowModel: createSortedRowModel(), sortFns: { alphanumeric: sortFn_alphanumeric, datetime: sortFn_datetime, text: sortFn_text } }). It skips tree-shaking, so every feature lands in the bundle, and it still needs the row-model slots. Treat it as a last resort for a table you can't audit, not as the recommendation.
A row model that a prop turns on or off
useTable registers features once, so a features object cannot follow a prop. Register the feature and toggle its behavior through a table option instead. manualPagination hands the pre-paginated rows straight through, and the table re-reads it on every render:
Before:
After:
manualSorting, manualFiltering, manualGrouping, and manualExpanding work the same way for their row models.
Step 2: replace the row models
v8 turned a row model on by passing a get*RowModel() option. v9 pairs a feature (the state and methods) with a row-model slot (the client-side processing). Both go in tableFeatures. If a slot is present without its feature, tableFeatures fails to typecheck with a message that names the missing feature.
Three rules that tableFeatures enforces at the type level:
sortedRowModelrequiresrowSortingFeature,filteredRowModelrequirescolumnFilteringFeature,paginatedRowModelrequiresrowPaginationFeature,expandedRowModelrequiresrowExpandingFeature, andgroupedRowModelrequirescolumnGroupingFeature.globalFilteringFeaturerequirescolumnFilteringFeature. The default global filter isfilterFn_includesStringitself, and it needs nofilterFnsregistration. A column filter with"auto"or a string name does: see Register filterFns.sortFns,filterFns, andaggregationFnsrequirerowSortingFeature,columnFilteringFeature, androwAggregationFeature.
Register filterFns
A column's filterFn defaults to "auto", which picks a built-in filter by the column's first non-null value. In v9, "auto" and every string name resolve against the filterFns slot on your features object. With no slot, the column has no filter function: table.getFilteredRowModel() returns every row, and only a development build logs a warning. A function value for filterFn bypasses the slot, and manualFiltering: true runs no filter function on the client. The global filter's "auto" default is filterFn_includesString itself, so a table with only a search box needs no slot either.
Register the filters your columns resolve by name. The registry keys become the only valid string values for filterFn:
Before:
After:
columnFilters state, onColumnFiltersChange, column.setFilterValue(), column.getFilterValue(), and column.getIsFiltered() are unchanged. A custom named filter goes in the same object, and its key becomes a valid filterFn string.
Register aggregationFns for grouped totals
v8 aggregated every non-grouping column on a group row by default: getValue() on a number column returned the sum of the leaf rows, and on a Date column the extent, with no aggregationFn on the column. In v9 that behavior lives in rowAggregationFeature, and its "auto" default resolves sum and extent against the aggregationFns slot. With columnGroupingFeature alone, a group row's getValue() returns undefined for every non-grouping column, so a cell that renders props.getValue() goes blank on the group rows. rowAggregationFeature without the slot does the same, and only a development build logs a warning.
If a group row shows a value for a non-grouping column, register rowAggregationFeature and the aggregations its columns resolve by name. cell.getIsAggregated(), aggregatedCell, column.getAggregationValue(), and a column's own aggregationFn: "name" also need the feature. A column whose aggregationFn is a constructAggregationFn({ … }) value, not a name, bypasses the slot but still needs the feature. The registry keys become the only valid string values for aggregationFn.
ngrok's dashboard has a grouped usage table that also hides a column. Its usage columns hold numbers, so v8 summed them on each group row:
Before:
After:
row.getIsGrouped(), row.groupingValue, initialState.grouping, and initialState.expanded are unchanged.
Step 3: type the column helper and wrap the columns
createColumnHelper takes the features type first, and the column array must go through columnHelper.columns.
Before:
After:
The wrapper isn't cosmetic. Each accessor's TValue (string for email, number for amount) sits in a contravariant position, because the cell render prop receives it. A bare array of those definitions does not widen to the ColumnDef<typeof features, Payment>[] that useTable's columns option expects, and the call fails to typecheck. columnHelper.columns infers each element's type as a tuple member first and returns an array that is assignable. At runtime it returns the same array.
If a function builds the columns (a useMemo factory, or a function that takes a search string), return columnHelper.columns([…]) from it. columnHelper.display and columnHelper.group definitions go inside the same array.
Step 4: rename the sort options and register sortFns
Three renames, one new requirement.
Unchanged: enableSorting, sortUndefined, sortDescFirst, manualSorting, onSortingChange, SortingState, column.getIsSorted(), column.getCanSort(), column.toggleSorting(), column.clearSorting().
Before:
After:
Register sortFns
A column's sortFn defaults to "auto", which picks a built-in comparator by the column's values. In v9, "auto" and every string name resolve against the sortFns slot on your features object. With no slot, auto-sort logs a dev warning and falls back to sortFn_basic, which loses the natural ordering that alphanumeric gives (item2 before item10). DataTable.HeaderSortButton's sortingMode prop picks the icon and the toggle order; it does not pick the comparator. So every sortable table registers its comparators in the sortFns slot.
Register the three comparators auto-sort resolves by name. Numbers need no entry: auto-sort uses sortFn_basic for them directly. The registry keys become the only valid string values for sortFn:
sortFn: "basic" is a compile error until you add basic: sortFn_basic. A custom named comparator goes in the same object, and its key becomes a valid sortFn string:
TanStack still exports the full sortFns, filterFns, and aggregationFns registries, but v9 marks them deprecated. Do not spread them into the slots; import the functions you use.
Step 5: read table.state
table.getState() is gone. The instance exposes state as a property, typed from the registered features: table.state.sorting exists only when the table registers rowSortingFeature.
Before:
After:
Controlled state is unchanged: pass each slice through state and its on*Change callback (state: { sorting } with onSortingChange: setSorting, state: { globalFilter } with onGlobalFilterChange, state: { rowSelection } with onRowSelectionChange, state: { expanded } with onExpandedChange). initialState is unchanged.
v9 removed onStateChange. If a table used it to mirror the whole state, subscribe to the slices you need through their on*Change callbacks instead. For a subtree that should re-render on one slice, render table.Subscribe with a selector:
useTable(options) without a second argument re-renders the owning component on every state change, the same as v8. Leave it that way unless a profile shows a problem.
Step 6: add TFeatures to the types
Every instance type gains a leading TFeatures parameter: Column<TFeatures, TData, TValue>, Row<TFeatures, TData>, Table<TFeatures, TData>, ColumnDef<TFeatures, TData, TValue>, HeaderContext<TFeatures, TData, TValue>, and CellContext<TFeatures, TData, TValue>. TableOptions<TFeatures, TData> gains the same parameter, and features is a required key on it. A shared partial config either carries features or adds it to the Omit: Omit<TableOptions<typeof features, Payment>, "data" | "columns" | "features">. The tableOptions() helper keeps inference for such a config without the Omit: tableOptions({ features, autoResetPageIndex: false }).
For a helper that serves one table, pass typeof features:
Before:
After:
For a helper shared across tables, make it generic over TFeatures extends TableFeatures. Inside it, only the core API is available (column.id, column.columnDef, row.original, table.getRowModel()), because TypeScript can't know which features the caller registered:
A shared helper that needs a feature's API names the feature in its constraint and intersects the feature's column type. This is the shape mantle uses for DataTable.HeaderSortButton:
Do not intersect the alias with TableFeatures. Every key of TableFeatures is optional, so the bare Pick already satisfies Column's constraint. With the intersection, Column<SortableFeatures, …> claims every feature, and a consumer's compile error names an unrelated feature instead of sorting.
A wrapper that derives its props from ComponentProps<typeof DataTable.Row> needs one more change. ComponentProps erases the part's generics to their constraints, so renderExpanded comes out as (row: Row<TableFeatures, RowData>) => ReactNode. Row is invariant in TFeatures, so that callback cannot share a TFeatures with your concrete row. Omit both props and re-declare them against your features:
Inside a column definition you rarely write these types by hand: props.column, props.row, and props.table in header and cell are already typed from the helper's typeof features.
Step 7: type the meta through the features object
ngrok's apps type table.options.meta today with an as cast or a runtime narrowing helper. v9 adds two type-only slots to tableFeatures: tableMeta types table.options.meta, and columnMeta types columnDef.meta. Pass metaHelper<T>() as the value. It returns a phantom value: TanStack strips it at runtime and reads only its type.
Before:
After:
options.meta stays optional on the type, so read it with ?.. Delete the narrowing helper and the satisfies clause: useTable rejects a meta that doesn't match PaymentsTableMeta.
columnMeta works the same way for per-column metadata:
If the app merged TableMeta or ColumnMeta through declare module "@tanstack/react-table", delete that declaration. The slot replaces it, and the global interface still applies to any table that has no slot.
Step 8: register column visibility
Column visibility was always on in v8. In v9 it's a feature: columnVisibility state, table.getVisibleLeafColumns(), table.getVisibleFlatColumns(), row.getVisibleCells(), column.getIsVisible(), and column.toggleVisibility() exist only when the table registers columnVisibilityFeature.
Before:
After:
If the table never hides a column, don't register the feature. Read table.getAllLeafColumns() and row.getAllCells() instead; both are core API. DataTable.Row, DataTable.EmptyRow, and DataTable.ExpandedRow work either way: they render visible cells when the table registers the feature and every cell when it doesn't.
Step 9: register row selection
Row selection was always on in v8. In v9, row.getIsSelected(), row.toggleSelected(), row.getToggleSelectedHandler(), table.getSelectedRowModel(), table.getIsAllRowsSelected(), table.getIsSomeRowsSelected(), table.toggleAllRowsSelected(), table.getToggleAllRowsSelectedHandler(), the rowSelection state slice, and the enableRowSelection option exist only when the table registers rowSelectionFeature.
Before:
After:
enableRowSelection still defaults to true. RowSelectionState and every method above keep their v8 names; only the registration is new. See Row selection with checkboxes for the full recipe.
Step 10: pinning, sizing, and resizing
Mantle apps rarely use these. If yours does:
start and end are logical: in a left-to-right layout start is left and end is right.
Mantle parts and the features they need
Every DataTable part infers its generics from the table, column, or row you pass. You never write TFeatures by hand. Two parts refuse an instance from a table that lacks the feature they call, and the prop does not typecheck.
expandedRowId(row) accepts a Row<TFeatures, TData> from any table.
What not to do
- Do not import from
@tanstack/react-table/legacy, and do not calluseLegacyTableorlegacyCreateColumnHelper. Mantle does not re-export them, and a direct import would need@tanstack/react-tablein yourpackage.json, which the app must not carry. The legacy layer is a v8 emulation with v9 costs, so a table that lands there still needs this migration later. - Do not use the
get*RowModelstubs (getCoreRowModel,getSortedRowModel, and the rest). They are deprecated markers foruseLegacyTable, and the main entry does not export them.import { getCoreRowModel } from "@ngrok/mantle/data-table"is a compile error. - Do not spread
stockFeaturesinto every table. It defeats tree-shaking and hides which features a table depends on. - Do not call
table.getState(). It no longer exists. - Do not cast
table.options.meta. Type it throughtableMeta. - Do not add
@tanstack/react-tableas a direct dependency. Every export you need comes from@ngrok/mantle/data-table.
Find/replace recipe
Apply these in order. Each row is a mechanical edit; the notes call out the ones that need a second look.
Example: complete before/after
A list page with sorting, a search box, page controls, and a delete action that reaches the page through meta.
Before:
After:
The JSX is identical. The diff is the imports, the features object, the helper's type arguments, the columnHelper.columns wrapper, sortFn, the meta read, and table.state.
Checklist
Work through this for every file that imports from @ngrok/mantle/data-table:
@tanstack/react-tableis not in the app'spackage.json, and no file imports from it or from@tanstack/react-table/legacy.- Every
useReactTableisuseTable, and every call passesfeatures. - Every
featuresobject is built withtableFeaturesat module scope. - No
getCoreRowModelremains. - Every
get*RowModel()option is now a feature plus a row-model slot insidetableFeatures. - A table with a global filter registers both
columnFilteringFeatureandglobalFilteringFeature. - A table whose column filters use
"auto"or a string name registers afilterFnsslot, withincludesString: filterFn_includesStringfor the"auto"default on string columns. - A grouped table whose group rows show a total registers
rowAggregationFeature, andsum: aggregationFn_suminaggregationFnswhen those columns use"auto"(extent: aggregationFn_extentforDatecolumns). - Every
createColumnHelper<T>()iscreateColumnHelper<typeof features, T>(). - Every column array, including one a function returns, goes through
columnHelper.columns([…]). - Every
sortingFnissortFn, and every sortable table registers its comparators in thesortFnsslot. - No
table.getState()oronStateChangeremains. - Every
Column,Row,Table,ColumnDef,HeaderContext,CellContext, andTableOptionstype has itsTFeaturesargument. - Every wrapper typed from
ComponentProps<typeof DataTable.Row>omits and re-declaresrowandrenderExpanded. - Every table that reads
table.options.metatypes it throughtableMeta: metaHelper<T>(), with noascast and nodeclare module. - A table that reads
columnVisibility,getVisibleLeafColumns(), orgetVisibleCells()registerscolumnVisibilityFeature. - A table that selects rows registers
rowSelectionFeature. - A table that pins, sizes, or resizes columns registers the matching feature and uses
startandend. - Every
DataTable.HeaderSortButtonsits in a table withrowSortingFeature, and everyDataTable.RowExpandButtonsits in a table withrowExpandingFeature. tscpasses, and the table sorts, filters, paginates, expands, selects, and totals its group rows the way it did before.