TL;DR: TanStack Table is a headless table engine - it handles sorting, filtering, pagination, and row selection logic but renders nothing. You write the HTML. Memoize your
dataarray or it'll re-render on every tick. ~15kB. Official docs: tanstack.com/table.
What is TanStack Table?
TanStack Table is a headless table engine - it computes all the logic for sorting, filtering, pagination, column resizing, and row selection, but renders nothing itself. You write the HTML and CSS; the library provides the state and helpers.
This approach means the library works with any styling system (Tailwind, CSS-in-JS, MUI, shadcn) and any data source. It's the right choice when pre-built table components are too opinionated for your design system.
When to use it
Use TanStack Table when:
- Your design system requires full control over table markup
- You need features like column pinning, row expansion, or virtual scrolling
- You're building a data-heavy admin interface or analytics dashboard
Use a pre-built table (MUI DataGrid, AG Grid Community) if you need Excel-like editing, enterprise features, or you don't mind the design lock-in.
Key Features
- Headless - renders no HTML or CSS; you own the markup
- Sorting - multi-column, custom sort functions, server-side support
- Filtering - global filter, column-level filters, faceted filtering
- Pagination - manual or automatic, with
pageSizeandpageIndex - Row selection - checkbox selection, single/multi select
- Column features - pinning, resizing, visibility toggling, grouping
- Virtualization - integrates with TanStack Virtual for large datasets
Installation
npm install @tanstack/react-table
Basic Usage
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
flexRender,
type ColumnDef,
type SortingState,
} from '@tanstack/react-table'
import { useState } from 'react'
interface User { id: string; name: string; email: string; role: string }
const columns: ColumnDef<User>[] = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
{ accessorKey: 'role', header: 'Role' },
]
function UsersTable({ data }: { data: User[] }) {
const [sorting, setSorting] = useState<SortingState>([])
const table = useReactTable({
data,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
return (
<table>
<thead>
{table.getHeaderGroups().map(group => (
<tr key={group.id}>
{group.headers.map(header => (
<th
key={header.id}
onClick={header.column.getToggleSortingHandler()}
style={{ cursor: 'pointer' }}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: ' ^', desc: ' v' }[header.column.getIsSorted() as string] ?? ''}
</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>
)
}
TypeScript Tips
Define column types precisely with ColumnDef<TData, TValue>:
// Custom cell renderer with typed row data
const columns: ColumnDef<User>[] = [
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
// row.original is typed as User
const status = row.original.status
return <StatusBadge status={status} />
},
},
]
Common Gotchas
- Stable
datareference - TanStack Table re-runs row models whendatachanges. Memoize your data array withuseMemoto avoid infinite re-renders - Server-side operations - set
manualSorting: true,manualFiltering: true, andmanualPagination: true, then handle state changes yourself with API calls flexRenderis required - don't try to rendercolumnDef.headerdirectly; it can be a string, function, or component
What "headless" buys you and what it costs
Headless means the library hands you logic and zero markup. That sounds like extra work, and it is, at first. You write every <table>, <tr>, and <td> yourself. But the payoff is total control. Your table inherits your design system instead of fighting it. No overriding a grid library's CSS specificity wars, no theme that almost matches your brand.
The cost is real on day one and shrinks fast after. Once you've written your render layer once, you reuse it across every table in the app. Compare that to a pre-built grid where customizing the cell renderer means reading 40 pages of config docs. The honest tradeoff: pick headless when design control matters more than time-to-first-table. Pick a batteries-included grid when you need Excel-like editing yesterday.
Client-side versus server-side data
Small datasets live happily in the browser. Load a few thousand rows, hand them to TanStack Table, and let it sort, filter, and paginate in memory. Snappy and simple.
Cross into tens of thousands of rows or data you can't fully ship to the client, and you flip to manual mode. Set manualSorting, manualFiltering, and manualPagination to true. Now the table stops doing the work itself and instead reports state changes to you. You translate sorting and page state into query parameters, fetch the matching slice from your backend, and feed it back in. The table becomes a controller for your server query rather than the data engine. Most production admin panels end up here.
Row selection, expansion, and column control
Beyond the basics, the feature set is where this library earns its download count. Checkbox row selection tracks selected IDs for you, which you wire to bulk actions like delete or export. Row expansion renders detail panels or nested sub-rows under a parent, perfect for order lines under an order.
Column features round it out: pin a column so it stays put during horizontal scroll, let users resize columns by dragging the border, and toggle column visibility through a menu. Each feature is opt-in through a getXxxRowModel import, so your bundle only carries what you use. That tree-shaking discipline keeps the footprint reasonable even with many features enabled.
Scaling to huge tables
There's a ceiling on how many DOM rows a browser tolerates before scrolling stutters, usually a few thousand. TanStack Table doesn't solve rendering volume on its own - it computes logic, not DOM. For genuinely large tables you pair it with virtualization. The table instance gives you the full sorted and filtered row model; a virtualizer renders only the rows in the viewport.
The split is clean and worth remembering: the table owns sorting, filtering, and selection logic, while the virtualizer owns which DOM nodes exist. They share the same row index, so mapping between them is straightforward. This combination handles a hundred thousand rows without the page locking up.
Typing tables with generics
The generic useReactTable<TData> flows your row type through everything. Define ColumnDef<User>[] and row.original is typed as User inside every cell renderer. No casting, no guessing. This catches a real bug class early - reference a column key that doesn't exist on your type and the compiler stops you before runtime. For data-heavy apps where the shape changes often, that type safety pays for itself.
Related
- TanStack Virtual - pair with TanStack Table to virtualise rows when rendering thousands of records
- TanStack Query - use
useQueryto fetch and cache table data; TanStack Table handles presentation - React Resizable Panels - build IDE-style layouts with a resizable sidebar alongside your data table
- TypeScript Generics Guide -
useReactTable<TData>()is fully generic; understand generics to correctly type column definitions