Note
v9.0.0-beta.48/beta.49 split aggregation out of columnGroupingFeature into a new rowAggregationFeature (stockFeatures includes both). If you declare features explicitly, add rowAggregationFeature anywhere you use aggregationFns, aggregationFn, aggregatedCell, cell.getIsAggregated(), or column.getAggregationValue(). Aggregation function definitions, row-depth selection, and the getAggregationValue signature also changed. See Grouping and Aggregation below.
Note
v9.0.0-beta.38 renames column pinning from physical left/right terminology to logical start/end terminology (in LTR layouts start usually means left; in RTL it usually means right). Update columnPinning.left/right to columnPinning.start/end, column.pin('left' | 'right') to column.pin('start' | 'end'), and getLeft*/getRight* APIs to getStart*/getEnd*. See Column Pinning for the full mapping.
Note
v9.0.0-beta.10 moves row model factories and the filterFns/sortFns/aggregationFns registries onto the features object (the separate rowModels option is gone, and the factories no longer take arguments). See the row models section below for the new shape.
TanStack Table V9 is a major release with significant internal architectural improvements while maintaining the core table logic you're familiar with. Here are the key changes:
The main migration is changing from the React adapter used through preact/compat to the native Preact adapter: useReactTable becomes useTable, and get*RowModel options become feature and row model factory slots on tableFeatures.
TanStack Table v8 did not have an officially released Preact adapter. If you used TanStack Table in a Preact app on v8, you were most likely using @tanstack/react-table through preact/compat.
This guide is for migrating that setup to the native v9 @tanstack/preact-table adapter. After this migration, TanStack Table's Preact packages should not be the reason your table code requires preact/compat; any remaining compat aliases should come from the rest of your app or other dependencies.
// v8 / before: Preact app using the React adapter through preact/compat
import { useReactTable } from '@tanstack/react-table'
const table = useReactTable(options)
// v9: native Preact adapter
import { useTable } from '@tanstack/preact-table'
const table = useTable(options)In v9, a table must declare its feature set. Features, Row Models, and Row Model processing "Fns" are defined on the new features table option.
In v8 React-adapter code, all features were bundled and included in the useReactTable hook. In v9, you import only what you need.
// v8 / before: React adapter through preact/compat
import {
getCoreRowModel,
getSortedRowModel,
sortingFns,
useReactTable,
} from '@tanstack/react-table'
const table = useReactTable({
columns,
data,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
sortingFns,
})
// v9
import {
createSortedRowModel,
rowSortingFeature,
sortFns,
tableFeatures,
useTable,
} from '@tanstack/preact-table'
// All table options that concern including code modules (features, row models, Fns, etc.)
const features = tableFeatures({
rowSortingFeature, // new - import and pass the feature you want to use
sortedRowModel: createSortedRowModel(), // now row models are defined on the features object
sortFns, // now Fns are defined on the features object
// ...more features, row models, etc.
})
const table = useTable({
features, // new required option
columns,
data,
})Keep the features object outside the component when possible so the reference stays stable.
stockFeatures includes the common feature set and can be useful for smoke tests or early migration. It gives up the main bundle-size benefit of v9, so audit it before shipping.
import { stockFeatures, useTable } from '@tanstack/preact-table'
const table = useTable({
features: stockFeatures,
columns,
data,
})| Feature | Import Name |
|---|---|
| Column Faceting | columnFacetingFeature |
| Column Filtering | columnFilteringFeature |
| Column Grouping | columnGroupingFeature |
| Column Ordering | columnOrderingFeature |
| Column Pinning | columnPinningFeature |
| Column Resizing | columnResizingFeature |
| Column Sizing | columnSizingFeature |
| Column Visibility | columnVisibilityFeature |
| Global Filtering | globalFilteringFeature |
| Row Aggregation | rowAggregationFeature |
| Row Expanding | rowExpandingFeature |
| Row Pagination | rowPaginationFeature |
| Row Pinning | rowPinningFeature |
| Row Selection | rowSelectionFeature |
| Row Sorting | rowSortingFeature |
Row models process data for features like filtering, sorting, grouping, expanding, faceting, and pagination. In v9, row model factories and function registries are slots on tableFeatures rather than a separate rowModels option. Row model slots are type-checked, so each row model must be specified after its associated feature in the same tableFeatures call.
| Table V8 Option | Table V9 tableFeatures Slot | Table V9 Factory Function |
|---|---|---|
| getCoreRowModel() | (automatic) | Not needed, always included |
| getFilteredRowModel() | filteredRowModel | createFilteredRowModel() |
| getSortedRowModel() | sortedRowModel | createSortedRowModel() |
| getPaginationRowModel() | paginatedRowModel | createPaginatedRowModel() |
| getExpandedRowModel() | expandedRowModel | createExpandedRowModel() |
| getGroupedRowModel() | groupedRowModel | createGroupedRowModel() |
| getFacetedRowModel() | facetedRowModel | createFacetedRowModel() |
| getFacetedMinMaxValues() | facetedMinMaxValues | createFacetedMinMaxValues() |
| getFacetedUniqueValues() | facetedUniqueValues | createFacetedUniqueValues() |
Function registries move to slots too: pass filterFns, sortFns, and aggregationFns directly to tableFeatures instead of as factory arguments.
// v8 / before: React adapter through preact/compat
import {
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
sortingFns,
filterFns,
useReactTable,
} from '@tanstack/react-table'
const table = useReactTable({
columns,
data,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
sortingFns,
filterFns,
})
// v9
import {
columnFilteringFeature,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFns,
rowPaginationFeature,
rowSortingFeature,
sortFns,
tableFeatures,
useTable,
} from '@tanstack/preact-table'
const features = tableFeatures({
columnFilteringFeature,
rowPaginationFeature,
rowSortingFeature,
filteredRowModel: createFilteredRowModel(),
sortedRowModel: createSortedRowModel(),
paginatedRowModel: createPaginatedRowModel(),
filterFns,
sortFns,
})
const table = useTable({
features,
columns,
data,
})The filterFns, sortFns, and aggregationFns registry exports are now deprecated in favor of importing individual filterFn_*, sortFn_*, and aggregationFn_* functions and registering only the ones you use (or passing functions directly in column definitions with no registration at all). The full registries still work, but spreading them puts every built-in function in your bundle. Keep in mind that string names, including the default 'auto', only resolve functions you have registered.
// Before: registers every built-in function
import { filterFns, sortFns } from '@tanstack/preact-table'
const features = tableFeatures({
// ...other features and row models
filterFns,
sortFns,
})
// After: registers only the functions you use
import {
filterFn_includesString,
sortFn_alphanumeric,
sortFn_text,
} from '@tanstack/preact-table'
const features = tableFeatures({
// ...other features and row models
filterFns: { includesString: filterFn_includesString },
sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text },
})In v9, methods on rows, cells, columns, headers, and similar table objects are shared on the object's prototype instead of being created as arrow functions on each object. This improves memory usage, but it means destructuring those methods loses the this context they need to operate on the instance.
// v8 - worked because getValue closed over the row object
const { getValue } = row
const value = getValue('name')
// v9 - call the method on the instance
const value = row.getValue('name')This applies to row, cell, column, header, and related instance APIs, but not to the table instance itself. Audit code that destructures methods from table objects or passes them around as bare callbacks. Prefer calling them through the original object, for example row.getValue('name'), cell.getContext(), column.getCanSort(), or header.getContext().
Because these methods now live on the prototype, they also do not appear as own properties in Object.keys(instance), object spread, or JSON.stringify. A shallow clone like { ...row } copies row data but does not copy row methods. The methods are still callable normally because JavaScript looks them up through the prototype chain.
In v8 React-adapter examples, most code read all state through table.getState(). In v9, Preact can read a full snapshot, selected state, or a single atom.
| Surface | Use |
|---|---|
| table.state | The selected state from useTable; by default, this is the full registered table state. |
| table.store.state | A full framework-agnostic table state snapshot. |
| table.atoms.<slice>.get() | A narrow current-value read for one state slice. |
| table.Subscribe | A render boundary for selected table state or a specific atom/store source. |
| table.baseAtoms.<slice> | Internal writable atoms. Prefer feature APIs instead of writing these directly. |
// v8
const sorting = table.getState().sorting
const pagination = table.getState().pagination
// v9: full snapshot
const sorting = table.store.state.sorting
const pagination = table.store.state.pagination
// v9: narrow atom read
const sorting = table.atoms.sorting.get()By default, table.state is reactive and contains the full registered table state:
const table = useTable({
features,
columns,
data,
})
const { pagination, sorting } = table.statePass a custom selector when you want table.state to contain only the reactive state values that should cause this component to re-render.
const table = useTable(
{
features,
columns,
data,
},
(state) => ({
pagination: state.pagination,
sorting: state.sorting,
}),
)
table.state.paginationPassing (state) => state is equivalent to the default selector and is no longer necessary.
For large tables, opt the parent out and subscribe lower in the tree:
const table = useTable({ features, columns, data }, () => null)function PaginationFooter({ table }) {
return (
<table.Subscribe
selector={(state) => ({
pagination: state.pagination,
})}
>
{({ pagination }) => <span>Page {pagination.pageIndex + 1}</span>}
</table.Subscribe>
)
}table.Subscribe can also subscribe directly to one atom:
<table.Subscribe source={table.atoms.rowSelection}>
{(rowSelection) => <span>{Object.keys(rowSelection).length} selected</span>}
</table.Subscribe>The v8-style state plus on[State]Change pattern still works for migration and remains convenient for simple integrations. Keep it per-slice. For new v9 code, prefer owning state slices with external atoms (see External Atoms below), which give you fine-grained subscriptions without mirroring state through Preact.
import { useState } from 'preact/hooks'
import type { PaginationState, SortingState } from '@tanstack/preact-table'
const [sorting, setSorting] = useState<SortingState>([])
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const table = useTable({
features,
columns,
data,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
})The v8-style onStateChange callback is no longer part of the v9 useTable state model.
If you want to lift or listen to any state change, set up a subscription to the table.store:
const unsubscribe = table.store.subscribe((state) => {
console.log(state)
})Use external atoms when the app should own a table state slice and share it outside the table.
import { useCreateAtom, useSelector } from '@tanstack/preact-store'
import type { PaginationState, SortingState } from '@tanstack/preact-table'
function MyTable({ columns, data }) {
const sortingAtom = useCreateAtom<SortingState>([])
const paginationAtom = useCreateAtom<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const sorting = useSelector(sortingAtom)
const pagination = useSelector(paginationAtom)
const table = useTable({
features,
columns,
data,
atoms: {
sorting: sortingAtom,
pagination: paginationAtom,
},
})
return <span>Page {pagination.pageIndex + 1}</span>
}When atoms.pagination is provided, table writes like table.setPageIndex(2) write to that atom. Do not also pass state.pagination; atoms take precedence.
| v8 | v9 |
|---|---|
| sortingFn | sortFn |
| sortingFns | sortFns |
| getSortingFn() | getSortFn() |
| getAutoSortingFn() | getAutoSortFn() |
| SortingFn | SortFn |
| SortingFns | SortFns |
v9.0.0-beta.38 changes column pinning to use logical start/end terminology instead of physical left/right terminology. In LTR languages/layouts, start usually corresponds to left and end to right; in RTL languages/layouts, start usually corresponds to right and end to left. There are no deprecated aliases in beta.38.
| Before beta.38 | beta.38+ |
|---|---|
| columnPinning.left | columnPinning.start |
| columnPinning.right | columnPinning.end |
| column.pin('left') | column.pin('start') |
| column.pin('right') | column.pin('end') |
| column.getIsPinned() === 'left' | column.getIsPinned() === 'start' |
| column.getIsPinned() === 'right' | column.getIsPinned() === 'end' |
| row.getLeftVisibleCells() | row.getStartVisibleCells() |
| row.getRightVisibleCells() | row.getEndVisibleCells() |
| table.getLeftHeaderGroups() | table.getStartHeaderGroups() |
| table.getRightHeaderGroups() | table.getEndHeaderGroups() |
| table.getLeftLeafColumns() | table.getStartLeafColumns() |
| table.getRightLeafColumns() | table.getEndLeafColumns() |
| table.getLeftVisibleLeafColumns() | table.getStartVisibleLeafColumns() |
| table.getRightVisibleLeafColumns() | table.getEndVisibleLeafColumns() |
| table.getLeftTotalSize() | table.getStartTotalSize() |
| table.getRightTotalSize() | table.getEndTotalSize() |
| column.getStart('left') | column.getStart('start') |
| column.getAfter('right') | column.getAfter('end') |
| column.getIndex('left') | column.getIndex('start') |
| column.getIndex('right') | column.getIndex('end') |
This rename is about logical table regions, not automatic DOM direction handling. For sticky column pinning, prefer CSS logical properties like insetInlineStart and insetInlineEnd. The columnResizeDirection table option is unchanged.
At the table level, enablePinning split into column and row options:
const table = useTable({
enableColumnPinning: true,
enableRowPinning: true,
})Per-column enablePinning remains a column option.
Column resizing now has its own feature and state slice.
const features = tableFeatures({
columnSizingFeature,
columnResizingFeature,
})columnSizingInfo is now columnResizing, and onColumnSizingInfoChange is now onColumnResizingChange.
Aggregation is now its own feature, independent from column grouping. stockFeatures still includes both, so tables using it need no feature-registration change. If you declare features explicitly, add rowAggregationFeature whenever columns use aggregationFn, aggregatedCell, getAggregationValue, or cell.getIsAggregated. Add columnGroupingFeature and groupedRowModel only when you also group rows.
const features = tableFeatures({
rowAggregationFeature,
columnGroupingFeature, // only for grouped rows
groupedRowModel: createGroupedRowModel(),
aggregationFns: { sum: aggregationFn_sum },
})Custom aggregation callables have changed to context-based definitions:
// Table V8/earlier V9 betas
const total = (columnId, leafRows, childRows) =>
leafRows.reduce((sum, row) => sum + row.getValue(columnId), 0)
// Current V9
const total = constructAggregationFn({
aggregate: ({ rows, getValue }) =>
rows.reduce((sum, row) => sum + Number(getValue(row)), 0),
})The old per-function choice between childRows and leafRows is replaced by a single depth-selected context.rows, controlled by the maxAggregationDepth column option. The default (0) preserves V8's direct-child grouped aggregation; use Infinity to aggregate terminal leaf rows.
column.getAggregationValue() now takes a single options object instead of positional arguments:
// Table V8/earlier V9 betas
column.getAggregationValue(rows, maxDepth)
// Current V9
column.getAggregationValue({ rows, maxDepth })column.getAggregationFn() is now column.getAggregationFns() because a column can run multiple definitions, and the old callable AggregationFn/CreatedAggregationFn types are replaced by AggregationFnDef.
See the Grouping Guide and the Aggregation Guide for full documentation of the new capabilities.
Warning
Minor breaking change: row.getToggleSelectedHandler() now enables inclusive Shift range selection by default when rowSelectionFeature is enabled. Existing checkboxes or rows wired through this handler establish an anchor on an ordinary interaction and select or deselect the current display-order range on a Shift interaction. Direct row.toggleSelected() calls are unchanged.
Set enableRowRangeSelection: false to preserve the previous non-range handler behavior. The handler must receive an event that exposes Shift directly or through nativeEvent; see Shift Range Selection.
The "some rows selected" checks were simplified to mean "at least one row is selected":
| API | v8 | v9 |
|---|---|---|
| table.getIsSomeRowsSelected() | true when some but not all rows are selected | true when at least one row is selected |
| table.getIsSomePageRowsSelected() | true when some but not all page rows are selected | true when at least one page row is selected |
In v8 these returned false once every row was selected; in v9 they stay true. If you use them to drive an indeterminate "select all" checkbox, gate the indeterminate state on the matching all-selected check so it clears at full selection:
getIsSomeRowsSelected() && !getIsAllRowsSelected()
Some row APIs have changed from private to public:
| Table V8 | Table V9 |
|---|---|
| row._getAllCellsByColumnId() (private) | row.getAllCellsByColumnId() (public) |
All other internal APIs prefixed with _ have been removed. If you were using any of these, use their public equivalents:
TFeatures is now the first generic for column helpers and table types.
// v8
const columnHelper = createColumnHelper<Person>()
const columns: ColumnDef<Person>[] = [
columnHelper.accessor('age', {
header: 'Age',
sortingFn: 'alphanumeric',
}),
]
// v9
const columnHelper = createColumnHelper<typeof features, Person>()
const columns: Array<ColumnDef<typeof features, Person>> = columnHelper.columns(
[
columnHelper.accessor('age', {
header: 'Age',
sortFn: 'alphanumeric',
}),
],
)Use columnHelper.columns([...]) to preserve better inference for nested and grouped column definitions.
The React-adapter flexRender(def, context) function still exists for advanced cases, but v9 prefers the table-aware FlexRender component.
// v8
<td>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
// v9
<td><table.FlexRender cell={cell} /></td>You can also import the standalone component:
import { FlexRender } from '@tanstack/preact-table'
<FlexRender header={header} />
<FlexRender cell={cell} />
<FlexRender footer={footer} />tableOptions() is a type helper for reusable table option fragments.
import { tableOptions } from '@tanstack/preact-table'
const baseOptions = tableOptions({
features,
defaultColumn: {
minSize: 40,
},
})
const table = useTable({
...baseOptions,
columns,
data,
})Use it when several tables share feature registration, row models, defaults, or manual server-side settings.
createTableHook creates app-specific Preact table helpers with features, row models, and component conventions already bound.
import { createTableHook } from '@tanstack/preact-table'
const { useAppTable, createAppColumnHelper } = createTableHook({ features })
const columnHelper = createAppColumnHelper<Person>()
function PeopleTable({ data }) {
const table = useAppTable({
columns,
data,
})
}See the Composable Tables Guide for full patterns.
TFeatures is now the first generic on core table types.
ColumnDef<typeof features, Person>
Column<typeof features, Person>
Row<typeof features, Person>
Cell<typeof features, Person, TValue>
Table<typeof features, Person>Use the concrete features object for type inference:
const features = tableFeatures({
rowSortingFeature,
rowPaginationFeature,
})
const columnHelper = createColumnHelper<typeof features, Person>()If a helper must support stockFeatures, use StockFeatures:
import type { StockFeatures } from '@tanstack/preact-table'
type PersonColumn = ColumnDef<StockFeatures, Person>No more declaration merging required! (Although it still works if you want to keep using it)
Global declaration merging works exactly like it did in v8. The only change you need to make is updating the generics shape: both interfaces now take TFeatures as the first type parameter.
declare module '@tanstack/preact-table' {
interface ColumnMeta<TFeatures, TData, TValue> {
align?: 'left' | 'right'
}
}That's all that's required if you want to keep declaring meta types globally.
Optionally, v9 also adds a new way to declare meta types per-table without declaration merging. You can use type-only tableMeta/columnMeta slots on the features option, which only affect tables created with that features object:
const features = tableFeatures({
rowSortingFeature,
columnMeta: metaHelper<{ align?: 'left' | 'right' }>(),
})See the new Table and Column Meta Guide for full details on both approaches.
In v8, making a custom function usable as a string reference (like filterFn: 'fuzzy') required declare module augmentation of the FilterFns interface, and typing filter meta required augmenting FilterMeta. In v9, registering the function in the matching registry slot does both jobs with no global augmentation:
// v8 / before: React adapter through preact/compat
declare module '@tanstack/react-table' {
interface FilterFns {
fuzzy: FilterFn<unknown>
}
interface FilterMeta {
itemRank: RankingInfo
}
}
// v9 - register in the slot; the key becomes a valid string value
interface FuzzyFilterMeta {
itemRank?: RankingInfo
}
const features = tableFeatures({
columnFilteringFeature,
filteredRowModel: createFilteredRowModel(),
filterFns: { fuzzy: fuzzyFilter },
filterMeta: metaHelper<FuzzyFilterMeta>(),
})
// 'fuzzy' now typechecks in column defs for tables using these features
columnHelper.accessor('name', { filterFn: 'fuzzy' })The same pattern applies to sortFns (for sortFn string values) and aggregationFns (for aggregationFn string values). See the Fuzzy Filtering Guide for a complete example.
RowData is now constrained to record-like objects or arrays. Prefer object row types such as:
type Person = {
firstName: string
lastName: string
age: number
}