🗂TanStack Table
TanStack Table is a headless table library. It gives you the state and logic for sorting, filtering, pagination and row selection, and you write all of the markup and styles yourself. Use it when a table needs two or more of those features, or needs them to work against a server. If you only show a list of rows, a plain <table> and .map() is enough.
These notes are for v9. Most code online is v8 — see v8 → v9 below.
Install
npm install @tanstack/react-table
v9 is ESM-only.
Basic table
In v9 you declare the features a table uses with tableFeatures(). With no features you still get the core row model. The column helper takes the features type and your row type.
import {
createColumnHelper,
tableFeatures,
useTable,
} from '@tanstack/react-table'
type Person = {
id: string
name: string
email: string
age: number
}
const features = tableFeatures({})
const columnHelper = createColumnHelper<typeof features, Person>()
const columns = columnHelper.columns([
columnHelper.accessor('name', { header: 'Name' }),
columnHelper.accessor('email', {
header: 'Email',
cell: (info) => <a href={`mailto:${info.getValue()}`}>{info.getValue()}</a>,
}),
columnHelper.accessor('age', { header: 'Age' }),
])
export function PeopleTable({ data }: { data: Array<Person> }) {
const table = useTable({
features,
columns,
data,
getRowId: (row) => row.id,
})
return (
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} colSpan={header.colSpan} scope="col">
{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>
)
}
columnHelper.columns([...])keeps each column’s value type, soinfo.getValue()is typed.<table.FlexRender header={header} />and<table.FlexRender cell={cell} />render theheaderandcellcolumn options. The oldflexRender(def, context)function still works.getRowIdgives rows a stable id. Without it the id is the row index, which breaks selection when data changes.
Sorting, filtering and pagination
Each feature goes into tableFeatures() with its row model. Register only the sort and filter functions you use; string names like 'alphanumeric' resolve only when they are registered. The table keeps its own state, so you read it from table.state and change it with methods such as table.setGlobalFilter() and table.nextPage().
import {
columnFilteringFeature,
createColumnHelper,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_includesString,
globalFilteringFeature,
rowPaginationFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_basic,
tableFeatures,
useTable,
} from '@tanstack/react-table'
// Person is the same type as in the basic table
const features = tableFeatures({
rowSortingFeature,
columnFilteringFeature,
globalFilteringFeature,
rowPaginationFeature,
sortedRowModel: createSortedRowModel(),
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
filterFns: { includesString: filterFn_includesString },
})
const columnHelper = createColumnHelper<typeof features, Person>()
const columns = columnHelper.columns([
columnHelper.accessor('name', { header: 'Name', sortFn: 'alphanumeric' }),
columnHelper.accessor('email', { header: 'Email', enableSorting: false }),
columnHelper.accessor('age', { header: 'Age', sortFn: 'basic' }),
])
const ariaSort = {
asc: 'ascending',
desc: 'descending',
} as const
const arrows = { asc: ' ↑', desc: ' ↓' }
export function PeopleTable({ data }: { data: Array<Person> }) {
const table = useTable({
features,
columns,
data,
getRowId: (row) => row.id,
globalFilterFn: 'includesString',
initialState: {
pagination: { pageIndex: 0, pageSize: 20 },
},
})
return (
<>
<input
type="search"
aria-label="Search people"
placeholder="Search…"
value={table.state.globalFilter ?? ''}
onChange={(e) => table.setGlobalFilter(e.target.value)}
/>
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const sorted = header.column.getIsSorted()
return (
<th
key={header.id}
scope="col"
aria-sort={sorted ? ariaSort[sorted] : undefined}
>
{header.column.getCanSort() ? (
<button
type="button"
onClick={header.column.getToggleSortingHandler()}
>
<table.FlexRender header={header} />
{sorted ? arrows[sorted] : null}
</button>
) : (
<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>
<nav aria-label="Pagination">
<button
type="button"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</button>
<span>
Page {table.state.pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<button
type="button"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</button>
<select
aria-label="Rows per page"
value={table.state.pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
>
{[10, 20, 50].map((size) => (
<option key={size} value={size}>
{size} per page
</option>
))}
</select>
</nav>
</>
)
}
Global filtering needs columnFilteringFeature as well as globalFilteringFeature. The page index goes back to 0 when the filter or sort changes.
Set starting values with initialState. If your own code must own a slice of state (for example, to put it in the URL), pass state and the matching on[Slice]Change handler, the same as v8.
Row selection
Add rowSelectionFeature and a display column for the checkboxes. Render the table as in the basic example.
import {
createColumnHelper,
rowSelectionFeature,
tableFeatures,
useTable,
} from '@tanstack/react-table'
// Person is the same type as in the basic table
const features = tableFeatures({ rowSelectionFeature })
const columnHelper = createColumnHelper<typeof features, Person>()
const columns = columnHelper.columns([
columnHelper.display({
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
aria-label="Select all rows"
checked={table.getIsAllRowsSelected()}
ref={(el) => {
if (el) {
el.indeterminate =
table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected()
}
}}
onChange={table.getToggleAllRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
aria-label={`Select ${row.original.name}`}
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
/>
),
}),
columnHelper.accessor('name', { header: 'Name' }),
columnHelper.accessor('email', { header: 'Email' }),
])
export function useSelectablePeopleTable(data: Array<Person>) {
const table = useTable({
features,
columns,
data,
getRowId: (row) => row.id,
enableRowSelection: (row) => row.original.age >= 18,
})
// { [rowId]: true }
const selection = table.state.rowSelection
const selectedPeople = table
.getSelectedRowModel()
.rows.map((row) => row.original)
return { table, selection, selectedPeople }
}
rowSelectionstate is an object of selected row ids, which is whygetRowIdmatters.row.getToggleSelectedHandler()selects a range on Shift-click by default in v9. SetenableRowRangeSelection: falseto stop that.- In v9,
getIsSomeRowsSelected()staystruewhen all rows are selected, so check!getIsAllRowsSelected()for the indeterminate state.
Server-side sorting and pagination
When the server sorts and paginates, leave out the sorted and paginated row models and set manualSorting and manualPagination. Hold the state yourself, put it in the query key, and give the table the total with rowCount (or pageCount; use -1 when the total is unknown). See React Query for more on useQuery.
import { useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import {
createColumnHelper,
rowPaginationFeature,
rowSortingFeature,
tableFeatures,
useTable,
} from '@tanstack/react-table'
import type { PaginationState, SortingState } from '@tanstack/react-table'
// Person is the same type as in the basic table
type PeoplePage = {
rows: Array<Person>
rowCount: number
}
declare function fetchPeople(params: {
pageIndex: number
pageSize: number
sorting: SortingState
}): Promise<PeoplePage>
// No row models: the server sorts and paginates
const features = tableFeatures({ rowSortingFeature, rowPaginationFeature })
const columnHelper = createColumnHelper<typeof features, Person>()
const columns = columnHelper.columns([
columnHelper.accessor('name', { header: 'Name' }),
columnHelper.accessor('email', { header: 'Email' }),
columnHelper.accessor('age', { header: 'Age' }),
])
const emptyRows: Array<Person> = []
export function usePeopleTable() {
const [sorting, setSorting] = useState<SortingState>([])
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
})
const query = useQuery({
queryKey: ['people', pagination, sorting],
queryFn: () => fetchPeople({ ...pagination, sorting }),
placeholderData: keepPreviousData,
})
const table = useTable({
features,
columns,
data: query.data?.rows ?? emptyRows,
rowCount: query.data?.rowCount,
getRowId: (row) => row.id,
state: { sorting, pagination },
onSortingChange: (updater) => {
setSorting(updater)
// No sorted row model, so reset the page yourself
setPagination((prev) => ({ ...prev, pageIndex: 0 }))
},
onPaginationChange: setPagination,
manualSorting: true,
manualPagination: true,
})
return { table, query }
}
placeholderData: keepPreviousDatakeeps the current page on screen while the next one loads, so the table does not flash empty. Usequery.isFetchingfor a loading indicator.emptyRowsis defined outside the component.query.data?.rows ?? []makes a new array on each render.- Without the sorted or filtered row models, the table does not reset the page for you. Reset
pageIndexin the change handler, as above. - For a server-side search box, add
columnFilteringFeature,globalFilteringFeatureandmanualFiltering: true, putglobalFilterin the query key, and debounce the input so each key press does not start a request.
v8 → v9
useReactTableis nowuseTable.- The
featuresoption is required. UsetableFeatures({ ... }), orstockFeaturesfor all features as in v8 (bigger bundle). - Row models move into
tableFeatures():getSortedRowModel()becomessortedRowModel: createSortedRowModel(), and the same for filtered, paginated, expanded, grouped and faceted.getCoreRowModel()is gone; the core row model is always there. filterFns,sortFnsandaggregationFnsare registered ontableFeatures(). Prefer single imports such asfilterFn_includesStringandsortFn_alphanumeric.- Sorting renames:
sortingFn→sortFn,sortingFns→sortFns,SortingFn→SortFn. table.getState()is replaced bytable.state(ortable.store.state).onStateChangeis removed; the per-sliceon[Slice]Changehandlers remain.- Types take the features type first:
ColumnDef<typeof features, Person>,Row<typeof features, Person>,createColumnHelper<typeof features, Person>(). <table.FlexRender />and<FlexRender />are new.flexRender()still works.- Row, cell, column and header methods live on the prototype. Do not destructure them: use
row.getValue('name'), notconst { getValue } = row. - Column pinning uses
start/endinstead ofleft/right, andenablePinningis split intoenableColumnPinningandenableRowPinning. - Column sizing and resizing are two features.
columnSizingInfostate is nowcolumnResizing. table.Subscribeand a selector (the second argument touseTable) let parts of the UI re-render only when their slice of state changes.useLegacyTablefrom@tanstack/react-table/legacyaccepts v8-style options, to help you migrate in steps. It is deprecated.
Gotchas
- Keep
dataandcolumnsstable. Define columns outside the component, or useuseMemoif they depend on props. Never passdata={[]}ordata={items.filter(...)}inline: a new array on each render makes the table recompute, and with state updates this can cause an infinite render loop. Use a module-level empty array,useStateoruseMemo. - Use a real
<table>. Headless means the semantics are your job. Keep<table>,<thead>,<tbody>,<th scope="col">and<td>so screen readers can move by row and column. - Put
aria-sorton the<th>, only on the sorted column, withascendingordescending. - Sort with a
<button>in the header, not anonClickon the<th>or a<div>. A button gets focus, Enter and Space for free. - Label the controls. Checkboxes, the search input and the page-size select need an accessible name (
aria-labelor a<label>).
For thousands of rows, render only the rows on screen with TanStack Virtual.