Skip to content
Dashboard

A guide to TanStack Table (formerly React Table)

12 min read

Data-heavy React apps start with a simple table. Then, sorting, filtering, pagination, row selection, expandable details, saved column layouts, and rendering performance all add up to application code.

TanStack Table is built for that point in the project. It gives you the table logic without forcing a grid UI. That makes it useful for admin panels, analytics dashboards, financial interfaces, and observability surfaces where the design system matters as much as the data model.

Link to headingWhat is TanStack Table?

TanStack Table is a headless, framework-agnostic table library. It runs in React, Vue, Solid, Svelte, Qwik, Angular, and Lit, weighs about 14.6 kB minified plus gzipped, and renders nothing on its own. The official docs describe it as a headless UI library for building tables and data grids across TypeScript and JavaScript, with adapters for each supported framework.

If you worked with React Table v7, v8 is a ground-up rewrite of that library in TypeScript, expanded to additional frameworks. The bundle stayed small, with about 56.6 kB minified and 14.6 kB minified plus gzipped per bundlephobia for @tanstack/react-table@8.21.3. The core headless contract stayed intact.

The v8 API is the stable version this guide covers. A v9 beta exists, but its API is still changing, so production teams should treat it as a migration track rather than the default starting point.

Link to headingWhat headless means in practice

Headless means TanStack Table builds a table instance from your data and column definitions, then leaves every DOM element for the developer to write. The docs define headless UI as a term for libraries that provide the logic, state, processing, and API for UI elements and interactions, but do not provide markup, styles, or pre-built implementations, leaving 100% of the rendering to you.

In practice, TanStack Table handles the hard parts of complex UIs, including state, events, side effects, and data computation. You own everything that renders.

Component-based grids take a different approach. They give you a feature-rich, drop-in grid with prebuilt components and styling, often including theming. AG Grid is a familiar example. Headless table libraries ship the logic, state, utilities, and event handlers, then expect you to write the table markup yourself or wire that logic into your existing components.

Tools like AG Grid and MUI DataGrid provide more out of the box. The tradeoff is less control over the surrounding grid structure, even though you can still customize what goes inside cells.

Here is the minimal end-to-end pattern for TanStack Table in React:

import {
createColumnHelper,
flexRender,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table'
type User = { id: string; name: string; email: string }
const columnHelper = createColumnHelper<User>()
const columns = [
columnHelper.accessor('name', { header: 'Name' }),
columnHelper.accessor('email', { header: 'Email' }),
]
function UserTable({ data }: { data: User[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
)
}

The data array is the only data source. getCoreRowModel is the base row model every table includes. flexRender resolves headers and cells from either a string or a function renderer. flexRender is the TanStack Table helper that takes whatever you put in a column’s cell, header, or footer definition and renders it correctly, including calling render functions with the right context. You write all the <table>, <tr>, and <td> tags yourself. In exchange, the library imposes no styles or markup that could conflict with your design system.

Link to headingColumn definitions and type safety

A ColumnDef<TData> ties every column to one row shape, and createColumnHelper<T>() carries that type through accessors, cell renderers, and column meta with no manual annotation. createColumnHelper is a utility exposed from the table core that, when called with a row type, returns helpers for creating different column definitions with the highest possible type safety.

There are three accessor forms. The object-key form is the simplest:

columnHelper.accessor('firstName', {
cell: (info) => info.getValue(),
})
// OR as a plain ColumnDef
{ accessorKey: 'firstName' }

The function form handles computed values:

columnHelper.accessor(
(row) => `${row.firstName} ${row.lastName}`,
{
id: 'fullName',
}
)
// OR
{ id: 'fullName', accessorFn: (row) => `${row.firstName} ${row.lastName}` }

Cell renderers receive a typed context that includes row.original:

columnHelper.accessor('firstName', {
cell: (props) => (
<span>
{`${props.row.original.id} - ${props.getValue()}`}
</span>
),
})

The docs note one constraint on accessor return values. The accessed value is what TanStack Table uses to sort, filter, and group, so an accessor function should return a primitive that can be meaningfully compared and manipulated. Returning a full object from an accessor breaks built-in sort and filter functions.

ColumnMeta is extensible through declaration merging for per-column UI hints such as filterVariant. One known friction is that ColumnDef is a union type, so component boundaries sometimes need an assertion such as ColumnDef<TData, any>[].

Link to headingThe table instance API

useReactTable takes columns, data, and a chain of row-model functions, and returns a table instance whose getHeaderGroups, getRowModel, and getVisibleCells the JSX maps over. The minimal required options are data, columns, and getCoreRowModel. Opt-in row models go next to it in the same options object.

For uncontrolled state, set initialState and let the table manage updates internally. For controlled state, pass the state slice plus its corresponding on[State]Change callback (e.g., state: { sorting } with onSortingChange). The state you pass merges with the table's internal state to produce the final value, so you can control individual slices (e.g., sorting only) and leave the rest uncontrolled; the global onStateChange callback controls the entire table state at once.

getRowModel() returns the final processed model with rows, flatRows, and rowsById available.

The row models run in a fixed order:

  1. getCoreRowModel

  2. getFilteredRowModel

  3. getGroupedRowModel

  4. getSortedRowModel

  5. getExpandedRowModel

  6. getPaginationRowModel

getRowModel() then returns the result of that final step. When a manual-mode flag is set, the corresponding step is skipped and the upstream row model passes through unchanged.

Keep the data array stable. An inline [] or newly constructed array gives the table new input on every render and forces the row model to reprocess.

Link to headingThe feature surface, one by one

The TanStack Table API is intentionally modular. Enabling any new capability always follows the same pattern: import the required row model, define the state slice, and pass the configuration options to wire it into the instance. The runnable patterns below walk through this process for core data operations (sorting, filtering, pagination), row behaviors (selection, expansion, grouping), and column management (visibility, ordering, resizing, and pinning).

Link to headingSorting and multi-sort

Sorting state is a ColumnSort[].

type SortingState = ColumnSort[] // [{ id: string, desc: boolean }]

In the table options, pass getSortedRowModel and onSortingChange alongside the controlled sorting state.

const [sorting, setSorting] = React.useState<SortingState>([])
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
// enableMultiSort: false -- shift-click multi-sort is on by default
})

In the header, header.column.getToggleSortingHandler() cycles a column through unsorted and its two sort directions (by default, descending-first for numeric columns and ascending-first for string columns). header.column.getIsSorted() returns 'asc', 'desc', or false. For custom sort logic, the sortingFn column option accepts a typed SortingFn<TData>: (rowA, rowB, columnId) => number.

Link to headingGlobal filter and column filters

Per-column filter state is ColumnFilter[].

type ColumnFiltersState = ColumnFilter[] // [{ id: string, value: unknown }]

In the table options, pass getFilteredRowModel and onColumnFiltersChange alongside the controlled filter state.

const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const table = useReactTable({
data,
columns,
state: { columnFilters },
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
})
// In a column header: call column.setFilterValue(newValue)

To add a global filter (search across all columns), keep getFilteredRowModel in the table options (it powers global filtering too), optionally set a globalFilterFn, and update the filter value with table.setGlobalFilter(value). globalFilterFn can be either a custom FilterFn or one of TanStack Table’s built-in filter functions (for example, 'includesString'). A common pattern is to use column meta like filterVariant to drive each column’s filter UI from the column definition.

Link to headingPagination, client and server

Pagination state is { pageIndex: number; pageSize: number }:

const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const table = useReactTable({
data,
columns,
state: { pagination },
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
// pageCount and rowCount not needed for client-side pagination
})

To navigate, control the view using the built-in methods: table.nextPage(), table.previousPage(), table.firstPage(), table.lastPage(), and table.setPageIndex(n). To switch this to server-side mode, add manualPagination: true to your configuration and provide the total rowCount or pageCount.

Link to headingRow selection and expansion

Row selection tracks Record<string, boolean> keyed by row id:

const [rowSelection, setRowSelection] = React.useState({})
const table = useReactTable({
data,
columns,
state: { rowSelection },
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
})

For select-all, use table.getToggleAllRowsSelectedHandler() in the header cell and row.getToggleSelectedHandler() in the body cell.

{
id: 'select',
header: ({ table }) => <input type="checkbox" onChange={table.getToggleAllRowsSelectedHandler()} />,
cell: ({ row }) => <input type="checkbox" checked={row.getIsSelected()} onChange={row.getToggleSelectedHandler()} />,
}

Row expansion uses ExpandedState, either true or Record<string, boolean>.

const [expanded, setExpanded] = React.useState<ExpandedState>({})
const table = useReactTable({
data,
columns,
state: { expanded },
onExpandedChange: setExpanded,
getSubRows: (row) => row.subRows, // teach the table where sub-rows live
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
})

The getSubRows callback is what tells the row model about nested data; without it, getExpandedRowModel has nothing to expand. row.getCanExpand() and row.getToggleExpandedHandler() are the per-row toggles.

Link to headingVisibility, ordering, sizing, pinning

All four are pure state slices, no row model required:

// Visibility
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
// call column.getToggleVisibilityHandler() per column
// Ordering
const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>([])
// call table.setColumnOrder(['col1', 'col2', ...])
// Resizing
// Set columnResizeMode: 'onChange' | 'onEnd' on the table
// header.getResizeHandler() returns onMouseDown / onTouchStart handler
// Pinning
const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({})
// column.pin('left') | column.pin('right') | column.pin(false)

For column pinning with separate header groups, use table.getLeftHeaderGroups(), table.getCenterHeaderGroups(), and table.getRightHeaderGroups() instead of table.getHeaderGroups().

Link to headingClient-side vs server-side data

Switching from client to server-side data means flipping the manual flags (manualSorting, manualFiltering, manualPagination), passing rowCount or pageCount to the table from the API, and owning the state transitions yourself.

In practice, the three manual flags mean the table stops processing that slice of state in the browser.

  • manualSorting: you sort data before passing it to the table.

  • manualFiltering: the table skips getFilteredRowModel for filtering.

  • manualPagination: the table expects pre-paginated rows instead of applying getPaginationRowModel.

For pageCount, the docs let you pass a known total, pass -1 when the total is unknown, or provide rowCount so the table can calculate pageCount internally.

Use client-side mode when the dataset is small and the query state does not need to be shared across views. Use server-side mode when queries are expensive, backend filters are shared across screens, or pagination must remain consistent across tabs.

One migration gotcha to watch out for: autoResetPageIndex defaults to false when manualPagination is true. If a filter changes while the user is on page 8, the table can stay on page 8 unless your filter handler also resets pagination state.

Link to headingVirtualization with TanStack Virtual

TanStack Table does not include virtualization. If you need to render tens of thousands of rows smoothly, pair it with TanStack Virtual.

TanStack Table builds a logical row model that includes sorting, filtering, pagination, and grouping. TanStack Virtual determines which rows are visible in the viewport and renders only those rows.

TanStack Virtual is also headless. Its core primitive is the Virtualizer, which tracks the visible window and returns the subset of items to render, without providing any markup or styles.

The official virtualized-rows example shows the canonical pairing.

import { useVirtualizer } from '@tanstack/react-virtual'
import { flexRender, type Table } from '@tanstack/react-table'
function TableBody({
table,
tableContainerRef,
}: {
table: Table<Person>
tableContainerRef: React.RefObject<HTMLDivElement>
}) {
const { rows } = table.getRowModel()
const rowVirtualizer = useVirtualizer({
count: rows.length, // total rows from the table's row model
estimateSize: () => 33, // estimated row height for scrollbar accuracy
getScrollElement: () => tableContainerRef.current,
measureElement:
typeof window !== 'undefined' &&
navigator.userAgent.indexOf('Firefox') === -1
? (element) => element?.getBoundingClientRect().height
: undefined,
overscan: 5,
})
return (
<tbody
style={{
display: 'grid',
height: `${rowVirtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index]
return (
<tr
data-index={virtualRow.index}
ref={(node) => rowVirtualizer.measureElement(node)}
key={row.id}
style={{
display: 'flex',
position: 'absolute',
transform: `translateY(${virtualRow.start}px)`,
width: '100%',
}}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} style={{ display: 'flex', width: cell.column.getSize() }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
)
})}
</tbody>
)
}

The table builds rows from getRowModel() after filtering, sorting, and pagination. The virtualizer receives that count and renders only the slice in the viewport. TanStack Table has no knowledge of scroll position; TanStack Virtual has no knowledge of column definitions. Their only shared surface is rows.length and the row index.

For very large infinite-scroll tables, TanStack Query should own server data and caching. TanStack Table can then manage the row model while TanStack Virtual handles the visible window.

Link to headingThe shadcn/ui Data Table pattern

The shadcn/ui Data Table is a widely used reference pattern for React data tables. The pattern uses TanStack Table for the logic, shadcn/ui primitives for the markup, and Tailwind for the styles. Every data table or datagrid is unique, behaving differently, with specific sorting and filtering requirements, and reading from different data sources, so it does not make sense to combine all of those variations into a single component.

The standard setup separates concerns across three files: page.tsx, columns.tsx, and data-table.tsx. The heavy lifting happens in data-table.tsx, which maps TanStack’s header groups and row model into shadcn/ui’s Table primitives like <Table>, <TableHeader>, <TableRow>, and <TableCell>. By keeping the logic headless, visual state stays in your markup, handled natively through reactive data attributes like data-state={row.getIsSelected() && 'selected'}.

columns.tsx is where the column definitions live, holding each column's accessor, header, and cell renderer. Because a header is just a function that receives the column instance, it is also where interactive behavior gets wired in. Here, a sortable header calls the column API directly:

'use client'
import { ColumnDef } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
export const columns: ColumnDef<Payment>[] = [
{
accessorKey: 'email',
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
Email
</Button>
),
},
]

From there, data-table.tsx renders these columns through shadcn/ui primitives, so the same definitions drive the markup you fully control.

Link to headingPairing with TanStack Query

TanStack Query holds the current page of server data and manages the loading and refetch lifecycle. TanStack Table reads from it through the manual-mode flags and rowCount.

The server-pagination pattern with useQuery keeps pagination state in both the query key and the table instance.

import { useQuery, keepPreviousData } from '@tanstack/react-query'
import {
useReactTable,
getCoreRowModel,
type PaginationState,
type ColumnDef,
} from '@tanstack/react-table'
// columns: ColumnDef<Person>[] is defined elsewhere in the module
function DataTable() {
const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
})
const dataQuery = useQuery<{ rows: Person[]; rowCount: number }>({
queryKey: ['data', pagination],
queryFn: () => fetchData(pagination),
placeholderData: keepPreviousData, // prevents 0-row flash on page turn
})
const defaultData = React.useMemo(() => [], [])
const table = useReactTable({
data: dataQuery.data?.rows ?? defaultData,
columns,
rowCount: dataQuery.data?.rowCount,
state: { pagination },
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
})
// render table and pagination controls
}

The queryKey includes pagination so TanStack Query re-fetches when the page or page size changes. keepPreviousData keeps the previous page visible while the next one loads, preventing a table flash to zero rows on every page turn. rowCount from the API response, let the table calculate pageCount internally, so you don't have to compute it separately. data and columns should also have stable references if possible, either by memoizing them or defining them outside the component, because useReactTable will reprocess the table whenever those references change, and can even trigger repeated render loops if they are recreated on every render.

For large infinite-scroll tables, useInfiniteQuery replaces useQuery, and TanStack Virtual renders only the visible window. The three-library pattern keeps caching, column state, and windowed rendering in separate layers.

Link to headingWhen TanStack Table is the right choice

TanStack Table fits analytics dashboards, admin panels, internal tools, financial UIs, and the eval and trace surfaces that engineering teams build on top of agent backends. It is less useful when the team wants styled headers, column menus, built-in editing, and accessibility scaffolding without having to own the markup.

Those omissions define the boundary of what TanStack Table does and does not do. TanStack Table does not provide <table> markup, CSS, virtualization, data fetching, drag-and-drop, or focus management. The application chooses those layers, often with TanStack Virtual for windowing, TanStack Query for server state, and a design-system table component for rendering.

Workload

TanStack Table owns

Your application owns

Analytics dashboard

Sorting, filters, grouped rows, visible columns

Query API, chart context, saved views

Admin panel

Selection, pagination, row actions, expansion

Permissions, mutations, audit history

Financial UI

Column sizing, pinning, virtualized row model

Pricing feeds, reconciliation, export rules

Eval or trace surface

Nested rows, global search, expandable detail views

Trace storage, long-running analysis jobs, backend retries

For data-heavy dashboards and internal tools, the table is usually the thin React shell over an I/O-heavy backend. When that backend powers agent eval, trace, and observability surfaces, it runs on Vercel's agentic infrastructure: Fluid compute for long-running, I/O-bound work, AI Gateway for routing across model providers, and Vercel Workflows for durable, multi-step analysis jobs.

Link to headingA starter checklist

Six questions can help you decide whether TanStack Table is the right pick.

  • Dataset size. Small datasets can stay client-side. Large or growing datasets need server-side operations, TanStack Virtual, or both.

  • Data architecture (Client vs. Server). Client-side datasets get sorting, filtering, and pagination handled automatically by the library. If your data is paginated on the server, you must take over state management: wiring up manualSorting, manualFiltering, and manualPagination, and manually syncing the table's state with your fetching layer.

  • Design-system constraints. If cells and headers must render through your components, TanStack Table fits. If the team wants a styled grid with no markup ownership, choose a component grid.

  • Virtualization needs. Confirm TanStack Virtual fits the table layout before committing to 50,000-row scrolling.

  • Framework choice. TanStack Table v8 supports React, Vue, Solid, Svelte, Qwik, Angular, and Lit.

  • Markup appetite. Every header, cell, and pagination control is yours to write, which is the main cost of using a headless library.

Link to headingFrequently asked questions

Link to headingWhat version does this guide cover?

The examples use TanStack Table v8 and @tanstack/react-table@8.21.3.

Link to headingShould I wait for v9?

Use v8 for production work. The v9 beta targets a smaller baseline bundle and includes breaking API changes.

Link to headingDoes it support Vue, Solid, and Svelte?

Yes. Those three, plus Qwik, Angular, and Lit, all have official v8 adapters. The adapter swaps useReactTable for the framework-appropriate hook (e.g., useVueTable, createSolidTable), but the ColumnDef, row model, and feature API are identical across adapters.

Link to headingHow do I reset pageIndex on filter change?

Wire it manually. When manualPagination is true, autoResetPageIndex defaults to false, so a filter change does not automatically reset the page to 0. The filter handler should update the filter state and reset pagination state in the same callback.

Link to headingWhy do row selections clear when I sort or filter?

The table lacks stable row IDs. By default, TanStack Table uses array indices for row IDs. That works until the data array itself changes: with server-side sorting, filtering, or pagination, a re-fetch returns rows in a different order and index-based selection attaches to the wrong rows. To fix this, provide a getRowId function when creating the table (e.g., getRowId: (row) => row.id) so the table tracks selection using a stable, unique identifier.