@tanstack/react-table
TanStack Table is a headless datagrid engine. It owns the logic of a table (sorting, filtering, grouping, aggregation, pagination, row selection, column visibility, ordering, pinning, sizing, expansion) and owns none of the rendering. You give it a data array and a list of column definitions; it hands back header groups, rows, and cells along with the handlers and state you need, and you write every table, tr, th, and td yourself with your own CSS. That is the trade: no default look to fight, and no markup you did not write. The React package is a thin adapter over @tanstack/table-core, which also has Vue, Solid, Svelte, Angular, Preact, Lit, Ember, and Alpine adapters, so the same column definitions and mental model carry across frameworks. Version 9, released in August 2026, added a plugin architecture where every feature is opt-in and must be declared up front.
The right foundation when the table has to look like your product rather than like a grid widget, and the feature coverage is deeper than anything else headless. Go in knowing v9 is new and the ecosystem's examples have not caught up, so budget time to translate v8 material or stay on v8 until they do.
Use it if
- You have a design system or a component library and need the table to render as your components, not as someone else's grid with themes bolted on
- Your data is server-driven and you want the state machinery (sort direction, page index, filter values, selected row ids) without any client-side processing: keep the feature, drop its row model, set the matching manual option
- You need the same table behaviour in more than one framework, since table-core is shared and only the adapter changes
- You care about bundle size and only need part of a datagrid: v9 ships nothing for features you do not register, so a sortable table does not carry grouping and pinning code
- You want a grid that works out of the box: there is no markup, no styling, no virtualization, no CSV export, no column resize handles, and no data fetching. Everything visual is yours to build, and a shipping table is usually several hundred lines of your own JSX
- You are starting from tutorials or existing code: v9 landed on 2026-08-04 and is a rewrite. Features must be registered through tableFeatures(), row models moved into that object, createColumnHelper now takes the features type, and rendering goes through table.FlexRender. Nearly every blog post, Stack Overflow answer, and shadcn data-table example you will find is v8
- Your build is CommonJS: v9 is ESM only, with no require condition in its exports map and engines set to Node 20 or newer, so Jest without ESM configuration and older bundler setups need work before it will resolve at all
- TypeScript pain is a problem for you: the feature set is threaded through every generic as TFeatures, so the features object has to be stable and shared as typeof features, and a mismatch produces long inference errors far from the actual mistake
- You need spreadsheet behaviour: editable cells, clipboard range paste, undo, frozen panes with virtualized columns, and server-side grouping are all things you assemble yourself. AG Grid ships them
- You want v9's smaller-bundle promise for free: the docs put the total feature surface at roughly 25 kB in v9 against about 14 kB in v8, so spreading stockFeatures makes you bigger than v8, not smaller. The saving only arrives if you register features one at a time
Setup reality
npm install @tanstack/react-table is one dependency with no peer deps to chase beyond React 18 or newer, and Node 20 or newer for the build. The work is in the wiring. v9 requires a features option on every table, built with tableFeatures(), and that object should live at module scope because its type flows through your column helper, your column definitions, and the table instance. Data and columns both need stable references: an inline array literal or a columns array rebuilt each render throws away the table's internal structures on every pass, so use useMemo or module-level constants. Rendering headers and cells goes through table.FlexRender rather than the old flexRender call, though flexRender is still exported. useTable takes an optional second argument, a state selector, and omitting it subscribes the component to every registered state slice; narrowing it and using table.Subscribe deeper in the tree is how you stop a whole table re-rendering when one checkbox changes. Devtools live in @tanstack/react-table-devtools and need a key on the table options. If you are coming from v8, useLegacyTable from @tanstack/react-table/legacy gives a v8-shaped API, but it is deprecated on arrival and pulls in every stock feature.
Patterns
Render a table with no optional featuresbasic-table
import { createColumnHelper, tableFeatures, useTable } from '@tanstack/react-table'
type Person = { firstName: string; lastName: string; age: number }
const features = tableFeatures({})
const columnHelper = createColumnHelper<typeof features, Person>()
const columns = columnHelper.columns([
columnHelper.accessor('firstName', { header: 'First Name', cell: (info) => info.getValue() }),
columnHelper.accessor((row) => row.lastName, { id: 'lastName', header: () => <span>Last Name</span> }),
columnHelper.accessor('age', { header: 'Age' }),
])
function PeopleTable({ data }: { data: Array<Person> }) {
const table = useTable({ features, columns, data })
return (
<table>
<thead>
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((header) => (
<th key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder ? null : <table.FlexRender header={header} />}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getAllCells().map((cell) => (
<td key={cell.id}><table.FlexRender cell={cell} /></td>
))}
</tr>
))}
</tbody>
</table>
)
}features and columns sit at module scope on purpose. Rebuilding either inside the component discards the table's internal column and row structures on every render, and the features type is what makes columnHelper type-check against your row shape.
Add client-side sortingclient-sorting
import {
createSortedRowModel,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_text,
tableFeatures,
} from '@tanstack/react-table'
const features = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns: { alphanumeric: sortFn_alphanumeric, text: sortFn_text },
})
// in the header cell:
<th key={header.id} onClick={header.column.getToggleSortingHandler()}>
<table.FlexRender header={header} />
{{ asc: ' \u25B2', desc: ' \u25BC' }[header.column.getIsSorted() as string] ?? null}
</th>Three separate pieces are needed: the feature for state and APIs, the row model to actually reorder rows, and the named sort functions. Registering only the sortFns you use is what keeps the others out of your bundle; passing a function straight to a column's sortFn option skips registration entirely.
Paginate on the clientclient-pagination
import { createPaginatedRowModel, rowPaginationFeature, tableFeatures } from '@tanstack/react-table'
const features = tableFeatures({
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel(),
})
const table = useTable({
features,
columns,
data,
initialState: { pagination: { pageIndex: 0, pageSize: 20 } },
})
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>Prev</button>
<span>Page {table.state.pagination.pageIndex + 1} of {table.getPageCount()}</span>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>Next</button>Read state from table.state, which is the value your selector produced, rather than reaching into the store. autoResetPageIndex defaults to true, so a filter or sort change bounces you back to page one; set it false if you do not want that.
Filter by column and across the whole tablecolumn-and-global-filters
import {
columnFilteringFeature,
createFilteredRowModel,
filterFn_inNumberRange,
filterFn_includesString,
globalFilteringFeature,
tableFeatures,
} from '@tanstack/react-table'
const features = tableFeatures({
columnFilteringFeature,
globalFilteringFeature,
filteredRowModel: createFilteredRowModel(),
filterFns: { includesString: filterFn_includesString, inNumberRange: filterFn_inNumberRange },
})
// global search box
<input onChange={(e) => table.setGlobalFilter(e.target.value)} />
// per-column input
<input
value={(column.getFilterValue() ?? '') as string}
onChange={(e) => column.setFilterValue(e.target.value)}
/>globalFilteringFeature builds on columnFilteringFeature, so registering it alone is a type error that names the missing prerequisite. Filter inputs fire on every keystroke and re-run the row model over all rows, so debounce the handler once your dataset is more than a few thousand rows.
Checkbox selection with a stable row idrow-selection
import { rowSelectionFeature, tableFeatures } from '@tanstack/react-table'
const features = tableFeatures({ rowSelectionFeature })
const table = useTable({
features,
columns,
data,
getRowId: (row) => row.id,
enableRowSelection: true,
})
columnHelper.display({
id: 'select',
header: () => (
<input type="checkbox" checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()} />
),
cell: ({ row }) => (
<input type="checkbox" checked={row.getIsSelected()} disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()} />
),
})Without getRowId the selection state is keyed by array index, so re-sorting or refetching moves the selection to different records. table.getSelectedRowModel().flatRows gives you the selected rows themselves rather than the id map.
Let users show and hide columnscolumn-visibility
import { columnVisibilityFeature, tableFeatures } from '@tanstack/react-table'
const features = tableFeatures({ columnVisibilityFeature })
const table = useTable({
features,
columns,
data,
initialState: { columnVisibility: { age: false } },
})
{table.getAllLeafColumns().map((column) => (
<label key={column.id}>
<input type="checkbox" checked={column.getIsVisible()}
onChange={column.getToggleVisibilityHandler()} />
{column.id}
</label>
))}getAllLeafColumns walks past group headers so you get the actual data columns. Set enableHiding: false on a column definition to keep it always visible, and note that hidden columns still hold their filter and sort state.
Keep the state, let the server do the workserver-side-data
import { columnFilteringFeature, rowPaginationFeature, rowSortingFeature, tableFeatures } from '@tanstack/react-table'
// note: no row models registered, so nothing is processed in the browser
const features = tableFeatures({
rowSortingFeature,
rowPaginationFeature,
columnFilteringFeature,
})
const table = useTable({
features,
columns,
data: page.rows,
manualSorting: true,
manualPagination: true,
manualFiltering: true,
rowCount: page.total,
state: { sorting, pagination, columnFilters },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnFiltersChange: setColumnFilters,
})This is the split v9 makes explicit: the feature gives you state and APIs, the row model does client-side work. Omit the row model and the manual flags stop the table reordering or slicing the page you already fetched. Give rowCount (or pageCount) or getPageCount() returns -1.
Stop the whole table re-rendering on every state changenarrow-state-subscription
// only re-render this component when pagination changes
const table = useTable(
{ features, columns, data },
(state) => ({ pagination: state.pagination }),
)
// subscribe to one slice deeper in the tree instead
<table.Subscribe source={table.atoms.rowSelection} selector={(s) => s?.[row.id]}>
{(isSelected) => <tr className={isSelected ? 'selected' : ''}>{cells}</tr>}
</table.Subscribe>Omitting the selector subscribes the component to every registered state slice, which means one checkbox click re-renders the entire table. Selected values are compared shallowly, so return a small object rather than the whole state.
Give cells a typed callback through table metaeditable-cells-with-meta
import { metaHelper, tableFeatures } from '@tanstack/react-table'
type TableMeta = { updateData: (rowIndex: number, columnId: string, value: unknown) => void }
const features = tableFeatures({ tableMeta: metaHelper<TableMeta>() })
columnHelper.accessor('firstName', {
cell: ({ getValue, row, column, table }) => (
<input
defaultValue={getValue() as string}
onBlur={(e) => table.options.meta?.updateData(row.index, column.id, e.target.value)}
/>
),
})v9 declares meta types in tableFeatures with metaHelper instead of the v8 declaration-merging block, which means two tables in the same app can have different meta shapes. metaHelper is just a typed empty object, so nothing is added at runtime.
Define the feature set once for a whole appshared-table-hook
import { createTableHook } from '@tanstack/react-table'
export const { useAppTable, createAppColumnHelper } = createTableHook({
features: { rowSortingFeature, rowPaginationFeature },
debugTable: process.env.NODE_ENV === 'development',
})
// in a feature module:
const columnHelper = createAppColumnHelper<Person>()
const columns = columnHelper.columns([columnHelper.accessor('firstName', {})])
const table = useAppTable({ columns, data })This bakes the features and shared options into a hook so call sites stop repeating typeof features. The trade is one feature set for every table that uses the hook, so a page with one simple table and one full datagrid still ships both feature sets.
Render tens of thousands of rowsvirtualized-rows
import { useVirtualizer } from '@tanstack/react-virtual'
const { rows } = table.getRowModel()
const parentRef = React.useRef<HTMLDivElement>(null)
const rowVirtualizer = useVirtualizer({
count: rows.length,
estimateSize: () => 34,
getScrollElement: () => parentRef.current,
overscan: 5,
})
<div ref={parentRef} style={{ height: 600, overflow: 'auto' }}>
<div style={{ height: rowVirtualizer.getTotalSize(), position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map((vi) => {
const row = rows[vi.index]
return <TableRow key={row.id} row={row} start={vi.start} />
})}
</div>
</div>Virtualization is not part of the table: @tanstack/react-virtual is a separate install. Because you are absolutely positioning rows, semantic table markup stops laying out correctly and you either switch to divs with display: grid or set position and transform on each tr.
Run existing v8 code on v9 while you port itmigrate-from-v8
import {
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
legacyCreateColumnHelper,
useLegacyTable,
} from '@tanstack/react-table/legacy'
import { flexRender } from '@tanstack/react-table'
const columnHelper = legacyCreateColumnHelper<Person>()
const table = useLegacyTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})The legacy entry point keeps the v8 shape: no features option, get*RowModel options, and a subscription to all state. It is deprecated in the release that introduced it and pulls in every stock feature, so treat it as a temporary bridge rather than a destination.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| ag-grid-react | npm | You need a finished enterprise grid with virtualization, editing, clipboard, and Excel export today and can accept the bundle size and the paid tier for advanced features |
| material-react-table | npm | You are on Material UI and want a batteries-included table that wraps TanStack Table so you keep the same underlying model |
| @tanstack/table-core | npm | You are writing your own framework adapter or using a framework with no official package, since the React adapter is a thin layer over this |
| react-data-grid | npm | You want a rendered, virtualized, editable grid component with a much smaller API surface than AG Grid |