React TanStack Table: Advanced Data Grid Guide (2026)
TanStack Table v8 (formerly React Table) is the headless data grid library for React. "Headless" means it provides all the logic — sorting, filtering, pagination, row selection, column resizing — but zero UI. You own every pixel of the rendered table. This guide builds a full-featured data grid with TypeScript from scratch.
Table of Contents
Setup and Column Definitions
npm install @tanstack/react-table
import {
createColumnHelper,
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
flexRender,
} from '@tanstack/react-table'
// Data types
interface User {
id: string
name: string
email: string
role: 'admin' | 'user' | 'viewer'
status: 'active' | 'inactive'
createdAt: string
revenue: number
}
// Column helper provides type-safe column definitions
const columnHelper = createColumnHelper<User>()
const columns = [
// Accessor column — maps to a data key
columnHelper.accessor('name', {
header: 'Name',
cell: info => (
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-indigo-500 flex items-center justify-center text-white text-sm">
{info.getValue()[0]}
</div>
<span>{info.getValue()}</span>
</div>
),
enableSorting: true,
}),
columnHelper.accessor('email', {
header: 'Email',
cell: info => <span className="text-gray-400">{info.getValue()}</span>,
}),
columnHelper.accessor('role', {
header: 'Role',
cell: info => {
const role = info.getValue()
const colors = { admin: '#6366f1', user: '#22d3ee', viewer: '#64748b' }
return (
<span style={{
background: `${colors[role]}20`,
color: colors[role],
border: `1px solid ${colors[role]}40`,
borderRadius: 50,
padding: '2px 10px',
fontSize: 12,
fontWeight: 600,
}}>
{role}
</span>
)
},
filterFn: 'equals',
}),
columnHelper.accessor('revenue', {
header: 'Revenue',
cell: info => `$${info.getValue().toLocaleString()}`,
sortingFn: 'basic',
}),
// Display column — no data key, for actions
columnHelper.display({
id: 'actions',
header: 'Actions',
cell: ({ row }) => (
<div className="flex gap-2">
<button onClick={() => editUser(row.original)}>Edit</button>
<button onClick={() => deleteUser(row.original.id)}>Delete</button>
</div>
),
}),
]
Basic Table Rendering
function DataTable({ data }: { data: User[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
})
return (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
style={{
padding: '12px 16px',
textAlign: 'left',
color: '#64748b',
fontSize: 12,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
borderBottom: '1px solid rgba(99,102,241,0.2)',
}}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr
key={row.id}
style={{ borderBottom: '1px solid rgba(99,102,241,0.1)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(99,102,241,0.05)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
{row.getVisibleCells().map(cell => (
<td key={cell.id} style={{ padding: '12px 16px', color: '#e2e8f0' }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
Sorting
import { useState } from 'react'
import type { SortingState } from '@tanstack/react-table'
function SortableTable({ 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(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
style={{ cursor: header.column.getCanSort() ? 'pointer' : 'default' }}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{/* Sort indicator */}
{header.column.getIsSorted() === 'asc' && ' ↑'}
{header.column.getIsSorted() === 'desc' && ' ↓'}
{!header.column.getIsSorted() && header.column.getCanSort() && ' ↕'}
</th>
))}
</tr>
))}
</thead>
<tbody>{/* same as before */}</tbody>
</table>
)
}
Filtering
import type { ColumnFiltersState } from '@tanstack/react-table'
function FilterableTable({ data }: { data: User[] }) {
const [globalFilter, setGlobalFilter] = useState('')
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const table = useReactTable({
data,
columns,
state: { globalFilter, columnFilters },
onGlobalFilterChange: setGlobalFilter,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
globalFilterFn: 'includesString',
})
return (
<div>
{/* Global search */}
<input
value={globalFilter}
onChange={e => setGlobalFilter(e.target.value)}
placeholder="Search all columns..."
className="mb-4 w-full p-2 border rounded"
/>
{/* Per-column filter */}
<select
onChange={e => table.getColumn('role')?.setFilterValue(e.target.value || undefined)}
className="mb-4 p-2 border rounded"
>
<option value="">All roles</option>
<option value="admin">Admin</option>
<option value="user">User</option>
<option value="viewer">Viewer</option>
</select>
<p className="text-sm text-gray-500 mb-2">
{table.getFilteredRowModel().rows.length} of {data.length} rows
</p>
{/* table markup */}
</div>
)
}
Pagination
import type { PaginationState } from '@tanstack/react-table'
function PaginatedTable({ data }: { data: User[] }) {
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
const table = useReactTable({
data,
columns,
state: { pagination },
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
manualPagination: false, // true for server-side
})
return (
<div>
{/* table markup */}
<div className="flex items-center justify-between mt-4">
<div className="flex gap-2">
<button onClick={() => table.firstPage()} disabled={!table.getCanPreviousPage()}>«</button>
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>‹</button>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>›</button>
<button onClick={() => table.lastPage()} disabled={!table.getCanNextPage()}>»</button>
</div>
<span className="text-sm text-gray-500">
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</span>
<select
value={table.getState().pagination.pageSize}
onChange={e => table.setPageSize(Number(e.target.value))}
>
{[10, 25, 50, 100].map(size => (
<option key={size} value={size}>{size} per page</option>
))}
</select>
</div>
</div>
)
}
Row Selection
import type { RowSelectionState } from '@tanstack/react-table'
const selectionColumn = columnHelper.display({
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onChange={table.getToggleAllPageRowsSelectedHandler()}
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onChange={row.getToggleSelectedHandler()}
/>
),
})
function SelectableTable({ data }: { data: User[] }) {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const table = useReactTable({
data,
columns: [selectionColumn, ...columns],
state: { rowSelection },
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
enableRowSelection: true,
})
const selectedRows = table.getSelectedRowModel().rows.map(r => r.original)
return (
<div>
{selectedRows.length > 0 && (
<div className="mb-4 p-3 bg-indigo-500/10 border border-indigo-500/30 rounded-lg">
{selectedRows.length} rows selected
<button onClick={() => bulkDelete(selectedRows)} className="ml-4 text-red-400">
Delete selected
</button>
</div>
)}
{/* table markup */}
</div>
)
}
Column Visibility
function TableWithVisibility({ data }: { data: User[] }) {
const [columnVisibility, setColumnVisibility] = useState({})
const table = useReactTable({
data,
columns,
state: { columnVisibility },
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
})
return (
<div>
{/* Column toggle UI */}
<div className="flex gap-3 mb-4 flex-wrap">
{table.getAllLeafColumns().map(column => (
<label key={column.id} className="flex items-center gap-1 text-sm cursor-pointer">
<input
type="checkbox"
checked={column.getIsVisible()}
onChange={column.getToggleVisibilityHandler()}
/>
{column.id}
</label>
))}
</div>
{/* table markup */}
</div>
)
}
Server-Side Data
function ServerTable() {
const [sorting, setSorting] = useState<SortingState>([])
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
const [globalFilter, setGlobalFilter] = useState('')
// Fetch with server-side params
const { data, isLoading } = useQuery({
queryKey: ['users', sorting, pagination, globalFilter],
queryFn: () => fetchUsers({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: sorting[0]?.id,
sortDir: sorting[0]?.desc ? 'desc' : 'asc',
search: globalFilter,
}),
})
const table = useReactTable({
data: data?.rows ?? [],
columns,
pageCount: data?.pageCount ?? -1,
state: { sorting, pagination, globalFilter },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
manualSorting: true, // Tell TanStack Table not to sort locally
manualPagination: true, // Tell TanStack Table not to paginate locally
manualFiltering: true, // Tell TanStack Table not to filter locally
})
if (isLoading) return <TableSkeleton />
return <{/* table markup */}>
}