TanStack Table is a headless table library — it handles all the logic (sorting, filtering, pagination, row selection, column pinning) but renders nothing, leaving you full control over markup and styles. The v8 API is pure TypeScript: define columns with columnHelper.accessor(), pass data and config to useReactTable(), get back a table instance you render however you want. Claude Code writes TanStack Table column definitions, feature hooks, server-side sorting/filtering integration, and the TanStack Virtual integration for rendering 100,000-row grids without layout shifts.
CLAUDE.md for TanStack Table Projects
## Table Stack
- TanStack Table v8 with React (headless — bring your own styles)
- Styling: Tailwind for table cells; sticky headers via CSS
- Server-side: sorting/filtering/pagination state → API query params
- Virtualization: @tanstack/react-virtual for >500 row tables
- Column visibility: persisted to localStorage
- Row selection: checkboxes with indeterminate state for select-all
- Export: react-csv or xlsx for client-side export
Column Definitions
// tables/orders/columns.tsx
import { createColumnHelper, type ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns';
import { OrderStatusBadge } from '../components/OrderStatusBadge';
export type OrderRow = {
id: string;
createdAt: Date;
customerName: string;
status: 'pending' | 'shipped' | 'delivered' | 'cancelled';
totalCents: number;
itemCount: number;
};
const col = createColumnHelper<OrderRow>();
export const orderColumns: ColumnDef<OrderRow, any>[] = [
// Row selection checkbox
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected() ? true : undefined}
onChange={table.getToggleAllPageRowsSelectedHandler()}
className="rounded border-gray-300"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
className="rounded border-gray-300"
/>
),
enableSorting: false,
size: 40,
},
col.accessor('id', {
header: 'Order ID',
cell: info => (
<a href={`/orders/${info.getValue()}`} className="font-mono text-blue-600 hover:underline">
#{info.getValue().slice(0, 8)}
</a>
),
enableSorting: false,
}),
col.accessor('createdAt', {
header: 'Date',
cell: info => format(info.getValue(), 'MMM d, yyyy HH:mm'),
sortingFn: 'datetime',
}),
col.accessor('customerName', {
header: 'Customer',
filterFn: 'includesString',
}),
col.accessor('status', {
header: 'Status',
cell: info => <OrderStatusBadge status={info.getValue()} />,
filterFn: (row, columnId, filterValue: string[]) =>
filterValue.length === 0 || filterValue.includes(row.getValue(columnId)),
}),
col.accessor('totalCents', {
header: 'Total',
cell: info => `$${(info.getValue() / 100).toFixed(2)}`,
sortingFn: 'basic',
meta: { align: 'right' },
}),
col.accessor('itemCount', {
header: 'Items',
meta: { align: 'right' },
}),
];
Table Component with All Features
// tables/orders/OrderTable.tsx
import {
useReactTable, getCoreRowModel, getSortedRowModel,
getFilteredRowModel, getPaginationRowModel,
flexRender, type SortingState, type ColumnFiltersState, type RowSelectionState,
} from '@tanstack/react-table';
import { useState } from 'react';
import { orderColumns } from './columns';
interface OrderTableProps {
data: OrderRow[];
onSelectionChange?: (selectedIds: string[]) => void;
}
export function OrderTable({ data, onSelectionChange }: OrderTableProps) {
const [sorting, setSorting] = useState<SortingState>([
{ id: 'createdAt', desc: true }
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [columnVisibility, setColumnVisibility] = useState({
itemCount: false, // Hidden by default
});
const table = useReactTable({
data,
columns: orderColumns,
state: { sorting, columnFilters, rowSelection, columnVisibility },
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onRowSelectionChange: (updater) => {
const next = typeof updater === 'function' ? updater(rowSelection) : updater;
setRowSelection(next);
onSelectionChange?.(Object.keys(next).filter(k => next[k]));
},
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 25 } },
});
return (
<div className="space-y-2">
{/* Toolbar */}
<div className="flex items-center gap-2">
<input
placeholder="Filter customers..."
value={(table.getColumn('customerName')?.getFilterValue() as string) ?? ''}
onChange={e => table.getColumn('customerName')?.setFilterValue(e.target.value)}
className="h-8 w-64 rounded border border-gray-300 px-2 text-sm"
/>
<span className="text-sm text-gray-500">
{table.getFilteredRowModel().rows.length} results
{Object.keys(rowSelection).length > 0 && ` · ${Object.keys(rowSelection).length} selected`}
</span>
</div>
{/* Table */}
<div className="overflow-x-auto rounded border border-gray-200">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-gray-50 border-b">
{table.getHeaderGroups().map(headerGroup => (
<tr key={headerGroup.id}>
{headerGroup.headers.map(header => (
<th
key={header.id}
style={{ width: header.getSize() }}
className="px-3 py-2 text-left font-medium text-gray-700 whitespace-nowrap"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? ''}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id} className="border-b hover:bg-gray-50">
{row.getVisibleCells().map(cell => (
<td
key={cell.id}
className="px-3 py-2 text-gray-900"
style={{ textAlign: (cell.column.columnDef.meta as any)?.align }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between text-sm text-gray-700">
<div>
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</div>
<div className="flex gap-1">
<button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} className="px-2 py-1 rounded border disabled:opacity-50">←</button>
<button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} className="px-2 py-1 rounded border disabled:opacity-50">→</button>
</div>
</div>
</div>
);
}
Server-Side Sorting and Filtering
// tables/orders/ServerOrderTable.tsx — state → API params pattern
import { useQuery } from '@tanstack/react-query';
function useServerOrders(sorting: SortingState, filters: ColumnFiltersState, page: number) {
const params = new URLSearchParams({
page: String(page),
pageSize: '25',
...(sorting[0] && {
sortBy: sorting[0].id,
sortDir: sorting[0].desc ? 'desc' : 'asc',
}),
...Object.fromEntries(filters.map(f => [f.id, String(f.value)])),
});
return useQuery({
queryKey: ['orders', params.toString()],
queryFn: () => fetch(`/api/orders?${params}`).then(r => r.json()),
keepPreviousData: true, // Avoid flash on page change
});
}
For the React Query data fetching that drives server-side table state, the React Query guide covers query keys, caching, and optimistic updates. For the React 19 Server Components approach that can replace client-side tables for some use cases, the React 19 guide covers RSC data loading patterns. The Claude Skills 360 bundle includes TanStack Table skill sets covering column definitions, sorting/filtering, server-side integration, and row virtualization. Start with the free tier to try data grid column generation.