
Tanstack Table
- 93 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
tanstack-table is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- tanstack-table
- AI & Agent Building
- AI-coding skill
Tanstack Table by the numbers
- 93 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,706 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill tanstack-tableAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 93 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
TanStack Table
Overview
TanStack Table is a headless table library — it provides state management and logic but no UI. You supply the rendering; it handles sorting, filtering, pagination, selection, and more.
When to use: Complex data tables with sorting/filtering/pagination, server-side data, large datasets (1000+ rows with virtualization), row selection/expanding/grouping.
When NOT to use: Simple static tables (use <table> directly), display-only lists (use a list component), spreadsheet-like editing (consider AG Grid).
Quick Reference
| Pattern | API / Config | Key Points |
|---|---|---|
| Basic table | useReactTable({ data, columns, getCoreRowModel }) | Memoize data/columns to prevent re-renders |
| Column helper | createColumnHelper<T>() | Type-safe column definitions |
| Column groups | columnHelper.group({ header, columns }) | Nested headers; don't pin group columns |
| Sorting | getSortedRowModel() + onSortingChange | manualSorting: true for server-side |
| Filtering | getFilteredRowModel() + onColumnFiltersChange | manualFiltering: true for server-side |
| Pagination | getPaginationRowModel() + onPaginationChange | manualPagination: true + pageCount |
| Row selection | enableRowSelection + onRowSelectionChange | Set getRowId for stable selection keys |
| Column visibility | onColumnVisibilityChange | Toggle with column.toggleVisibility() |
| Column pinning | enableColumnPinning + initialState.columnPinning | Don't pin group columns (known bug) |
| Row expanding | getExpandedRowModel() + getSubRows | For nested/tree data |
| Column resizing | enableColumnResizing + columnResizeMode | onChange for live, onEnd for performant |
| Row grouping | getGroupedRowModel() + aggregationFn | Performance degrades at 10k+ rows |
| Server-side | manual*: true flags + include state in queryKey | All state in query key for proper refetching |
| Infinite scroll | useInfiniteQuery + flatten pages | Combine with TanStack Virtual for best perf |
| Virtualization | useVirtualizer from @tanstack/react-virtual | Disable when container hidden (tabs/modals) |
| React 19 Compiler | 'use no memo' directive | Required until v9 fixes compiler compat |
Common Operations
| Task | Method |
|---|---|
| Sort column | column.toggleSorting() |
| Filter column | column.setFilterValue(value) |
| First page | table.firstPage() |
| Next page | table.nextPage() |
| Previous page | table.previousPage() |
| Last page | table.lastPage() |
| Go to page | table.setPageIndex(n) |
| Select row | row.toggleSelected() |
| Hide column | column.toggleVisibility() |
| Get original data | row.original |
| Pin column | column.pin('left') |
| Resize column | header.getResizeHandler() |
| Expand row | row.toggleExpanded() |
Row Models
| Import | Purpose |
|---|---|
getCoreRowModel | Required |
getSortedRowModel | Sorting |
getFilteredRowModel | Filtering |
getPaginationRowModel | Pagination |
getExpandedRowModel | Expanding |
getGroupedRowModel | Grouping |
getFacetedRowModel | Faceted filter counts |
getFacetedUniqueValues | Unique values per facet |
getFacetedMinMaxValues | Min/max per facet |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Unstable data/columns reference | Memoize with useMemo or define outside component |
Missing manual* flags for server-side | Set manualPagination, manualSorting, manualFiltering |
| Query key missing table state | Include pagination, sorting, filters in queryKey |
Import from @tanstack/table-core | Import from @tanstack/react-table |
Using v7 useTable / Header / accessor | Use v8 useReactTable / header / accessorKey |
| Pinning group columns | Pin individual columns within the group, not parent |
| Grouping on 10k+ rows | Use server-side grouping or disable for large datasets |
| Column filter not clearing on page change | Reset pageIndex to 0 when filters change |
Missing 'use no memo' with React Compiler | Add directive to components using useReactTable |
Missing getRowId with row selection | Set getRowId: (row) => row.id for stable selection keys |
| Filter value type mismatch | Match value types; clear with undefined, not null |
Delegation
- Table pattern discovery: Use
Exploreagent - Server integration review: Use
Taskagent - Code review: Delegate to
code-revieweragent
If the tanstack-query skill is available, delegate data fetching, caching, and infinite query patterns to it.If the tanstack-virtual skill is available, delegate standalone virtualization patterns to it.If the tanstack-router skill is available, delegate URL search param sync for server-side table state to it.If the tanstack-start skill is available, delegate server functions for server-side data loading to it.References
- Column definitions, helpers, visibility, and selection
- Filtering: column, global, fuzzy, and faceted
- Server-side patterns with TanStack Query
- Infinite scroll with cursor pagination
- Reusable table components (Shadcn-styled)
- Virtualization for large datasets
- Column and row pinning
- Row expanding and grouping
- Known issues and solutions (15 documented)
- v7 to v8 migration guide
Column Definitions
Basic Column Types
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
} from '@tanstack/react-table';
const columns: ColumnDef<Person>[] = [
// Simple accessor
{ accessorKey: 'email', header: 'Email' },
// Computed value (requires id)
{
accessorFn: (row) => `${row.first} ${row.last}`,
id: 'fullName',
header: 'Name',
},
// Custom cell rendering
{
accessorKey: 'amount',
header: () => <div className="text-right">Amount</div>,
cell: ({ row }) => {
const amount = parseFloat(row.getValue('amount'));
const formatted = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
return <div className="text-right font-medium">{formatted}</div>;
},
},
// Actions column (display-only, no accessor)
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => <ActionMenu item={row.original} />,
},
];Type-Safe Column Helper
import { createColumnHelper } from '@tanstack/react-table';
const columnHelper = createColumnHelper<Person>();
const columns = [
columnHelper.accessor('name', { header: 'Name' }),
columnHelper.accessor((row) => `${row.first} ${row.last}`, {
id: 'fullName',
header: 'Full Name',
}),
columnHelper.display({
id: 'actions',
cell: ({ row }) => <ActionMenu row={row} />,
}),
];Custom Header with Sorting
{
accessorKey: 'email',
header: ({ column }) => (
<Button
variant="ghost"
onPress={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
Email
{column.getIsSorted() === 'asc' && <ChevronUp className="ml-2 size-4" />}
{column.getIsSorted() === 'desc' && <ChevronDown className="ml-2 size-4" />}
{!column.getIsSorted() && <ChevronsUpDown className="ml-2 size-4" />}
</Button>
),
}Select All Column
{
id: 'select',
header: ({ table }) => (
<Checkbox
isSelected={table.getIsAllPageRowsSelected()}
isIndeterminate={table.getIsSomePageRowsSelected()}
onChange={(isSelected) => table.toggleAllPageRowsSelected(isSelected)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
isSelected={row.getIsSelected()}
onChange={(isSelected) => row.toggleSelected(isSelected)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
}State Management Pattern
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const table = useReactTable({
data,
columns,
state: { sorting, columnFilters, columnVisibility, rowSelection },
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});Column Visibility Toggle
<MenuTrigger>
<Button variant="outline">Columns</Button>
<Popover>
<Menu>
{table
.getAllColumns()
.filter((col) => col.getCanHide())
.map((column) => (
<MenuItem key={column.id} onAction={() => column.toggleVisibility()}>
{column.getIsVisible() ? '✓ ' : ' '}
{column.id}
</MenuItem>
))}
</Menu>
</Popover>
</MenuTrigger>Pagination Controls
const table = useReactTable({
// ...
getPaginationRowModel: getPaginationRowModel(),
});
// Navigation
<Button onPress={() => table.previousPage()} isDisabled={!table.getCanPreviousPage()}>
Previous
</Button>
<Button onPress={() => table.nextPage()} isDisabled={!table.getCanNextPage()}>
Next
</Button>
// Page info
<span>
Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
</span>
// Page size selector
<Select
label="Rows per page"
selectedKey={String(table.getState().pagination.pageSize)}
onSelectionChange={(key) => table.setPageSize(Number(key))}
>
{[10, 20, 50].map((size) => (
<SelectItem key={size} id={String(size)}>{size}</SelectItem>
))}
</Select>Pagination Methods
| Method | Description |
|---|---|
firstPage() | Go to first page |
previousPage() | Previous page |
nextPage() | Next page |
lastPage() | Go to last page |
setPageIndex(n) | Go to page (0-indexed) |
setPageSize(n) | Set rows per page |
getPageCount() | Total pages |
getCanNextPage() | Can go forward |
getCanPreviousPage() | Can go back |
Column Resizing
Basic Setup
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
enableColumnResizing: true,
columnResizeMode: 'onChange', // Live resize preview
});Resize Modes
| Mode | Behavior | Performance |
|---|---|---|
onChange | Columns resize live while dragging | Lower |
onEnd | Columns resize only after drag completes | Higher |
Use onEnd for tables with many columns or expensive cell renderers.
Resize Handle
{
table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} style={{ width: header.getSize() }}>
{flexRender(header.column.columnDef.header, header.getContext())}
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={`resize-handle ${header.column.getIsResizing() ? 'isResizing' : ''}`}
/>
</th>
))}
</tr>
));
}Column Width Styles
Apply widths to both <th> and <td> elements:
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize,
}}Size Constraints
const columns = [
{
accessorKey: 'name',
header: 'Name',
size: 200, // Default width
minSize: 100, // Minimum resize width
maxSize: 400, // Maximum resize width
enableResizing: true, // Per-column toggle (default: true)
},
{
id: 'actions',
size: 60,
enableResizing: false, // Fixed-width column
},
];Column Groups (Nested Headers)
const columnHelper = createColumnHelper<Person>();
const columns = [
columnHelper.group({
header: 'Name',
columns: [
columnHelper.accessor('firstName', { header: 'First Name' }),
columnHelper.accessor('lastName', { header: 'Last Name' }),
],
}),
columnHelper.group({
header: 'Info',
columns: [
columnHelper.accessor('age', { header: 'Age' }),
columnHelper.accessor('status', { header: 'Status' }),
],
}),
];Do not pin group columns — pin individual columns within the group instead (see known issues).
Row ID Configuration
Set getRowId for stable row identity across page changes and data updates:
const table = useReactTable({
data,
columns,
getRowId: (row) => row.id, // Use your data's unique identifier
enableRowSelection: true,
state: { rowSelection },
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
});Without getRowId, TanStack Table uses the row index as the key. This causes selection to break across page changes because indices shift when data changes.
Rendering with flexRender
Always use flexRender() for both static and dynamic column content:
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{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>Column and Row Pinning
Column Pinning Setup
import { useReactTable, getCoreRowModel } from '@tanstack/react-table';
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
enableColumnPinning: true,
enableRowPinning: true,
initialState: {
columnPinning: {
left: ['select', 'name'],
right: ['actions'],
},
},
});Rendering Pinned Columns
Use CSS position: sticky on pinned cells within a single <table>:
import { type Column } from '@tanstack/react-table';
function getCommonPinningStyles<T>(column: Column<T>): React.CSSProperties {
const isPinned = column.getIsPinned();
return {
left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,
position: isPinned ? 'sticky' : 'relative',
width: column.getSize(),
zIndex: isPinned ? 1 : 0,
};
}
function PinnedTable({ table }) {
return (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
style={getCommonPinningStyles(header.column)}
className="bg-background"
>
{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}
style={getCommonPinningStyles(cell.column)}
className="bg-background"
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}Programmatic Pinning
column.pin('left'); // Pin column to left
column.pin('right'); // Pin column to right
column.pin(false); // Unpin column
row.pin('top'); // Pin row to top
row.pin('bottom'); // Pin row to bottom
row.pin(false); // Unpin rowColumn Pinning with Column Groups (Known Bug)
Pinning parent group columns (created with columnHelper.group()) causes incorrect positioning and duplicated headers. column.getStart('left') returns wrong values for group headers.
Source: GitHub Issue #5397
Workaround: Pin individual columns within the group, not the group itself:
// Check if a column is pinnable (no parent group)
const isPinnable = (column: Column<unknown>) => !column.parent;
// Pin individual columns, not the group
table.getColumn('firstName')?.pin('left');
table.getColumn('lastName')?.pin('left');
// Don't pin the parent group columnRow Pinning
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
enableRowPinning: true,
});
// Pin specific rows
row.pin('top');
row.pin('bottom');
// Get pinned rows for rendering
const topPinnedRows = table.getTopRows();
const centerRows = table.getCenterRows();
const bottomPinnedRows = table.getBottomRows();Row Expanding and Grouping
Row Expanding (Nested Data)
Setup
import {
useReactTable,
getCoreRowModel,
getExpandedRowModel,
} from '@tanstack/react-table';
// Data with nested children
const data = [
{
id: 1,
name: 'Parent Row',
subRows: [
{ id: 2, name: 'Child Row 1' },
{ id: 3, name: 'Child Row 2' },
],
},
];
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getExpandedRowModel: getExpandedRowModel(),
getSubRows: (row) => row.subRows, // Tell table where children are
});Rendering with Expand Button
function ExpandableTable({ table }) {
return (
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
<td>
{row.getCanExpand() && (
<button onClick={row.getToggleExpandedHandler()}>
{row.getIsExpanded() ? '▼' : '▶'}
</button>
)}
</td>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} style={{ paddingLeft: `${row.depth * 20}px` }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
);
}Programmatic Control
table.toggleAllRowsExpanded(); // Expand/collapse all
row.toggleExpanded(); // Toggle single row
table.getIsAllRowsExpanded(); // Check if all expanded
row.getCanExpand(); // Check if row has children
row.getIsExpanded(); // Check if row is expandedDetail Rows
For showing custom content (not nested data) when a row is expanded:
function TableWithDetails({ table, columns }) {
return (
<tbody>
{table.getRowModel().rows.map((row) => (
<Fragment key={row.id}>
<tr>
<td>
<button onClick={row.getToggleExpandedHandler()}>
{row.getIsExpanded() ? '▼' : '▶'}
</button>
</td>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
{row.getIsExpanded() && (
<tr>
<td colSpan={columns.length + 1}>
<div className="p-4 bg-muted">
Custom detail content for {row.original.name}
</div>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
);
}Row Grouping
Setup
import {
useReactTable,
getCoreRowModel,
getGroupedRowModel,
getExpandedRowModel,
} from '@tanstack/react-table';
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getGroupedRowModel: getGroupedRowModel(),
getExpandedRowModel: getExpandedRowModel(), // Groups are expandable
initialState: {
grouping: ['status'], // Group by 'status' column
},
});Column with Aggregation
const columns = [
{
accessorKey: 'status',
header: 'Status',
},
{
accessorKey: 'amount',
header: 'Amount',
aggregationFn: 'sum',
aggregatedCell: ({ getValue }) => `Total: ${getValue()}`,
},
];Built-in Aggregation Functions
sum, min, max, extent, mean, median, unique, uniqueCount, count
Rendering Grouped Rows
function GroupedTable({ table }) {
return (
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{cell.getIsGrouped() ? (
// Group header — show expand toggle + count
<button onClick={row.getToggleExpandedHandler()}>
{row.getIsExpanded() ? '▼' : '▶'}{' '}
{flexRender(cell.column.columnDef.cell, cell.getContext())} (
{row.subRows.length})
</button>
) : cell.getIsAggregated() ? (
// Aggregated value
flexRender(
cell.column.columnDef.aggregatedCell ??
cell.column.columnDef.cell,
cell.getContext(),
)
) : cell.getIsPlaceholder() ? null : (
// Regular cell
flexRender(cell.column.columnDef.cell, cell.getContext())
)}
</td>
))}
</tr>
))}
</tbody>
);
}Performance Warning: Grouping at Scale
Grouping causes significant performance degradation on medium-to-large datasets. With grouping enabled, render times can increase from <1 second to 30-40 seconds on 50k rows due to excessive memory usage in createRow calculations.
Source: Blog Post (JP Camara) | GitHub Issue #5926
Mitigations:
// 1. Disable grouping for large datasets
const shouldEnableGrouping = data.length < 10000;
// 2. Use server-side grouping instead
const table = useReactTable({
manualGrouping: true,
// Server returns pre-grouped data
});
// 3. Paginate to limit rows per page
// 4. Memoize row components
const MemoizedRow = React.memo(TableRow);Filtering
Column Filter
import { TextField } from '@oakoss/ui';
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const table = useReactTable({
data,
columns,
state: { columnFilters },
onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(),
getCoreRowModel: getCoreRowModel(),
});
// Filter input for a specific column
<TextField
label="Filter emails"
value={(table.getColumn('email')?.getFilterValue() as string) ?? ''}
onChange={(value) => table.getColumn('email')?.setFilterValue(value)}
/>;Global Filter
const [globalFilter, setGlobalFilter] = useState('');
const table = useReactTable({
data,
columns,
state: { globalFilter },
onGlobalFilterChange: setGlobalFilter,
getFilteredRowModel: getFilteredRowModel(),
getCoreRowModel: getCoreRowModel(),
});
<TextField
label="Search all columns"
value={globalFilter}
onChange={(value) => setGlobalFilter(value)}
/>;Built-in Filter Functions
| Function | Description |
|---|---|
includesString | Case-insensitive contains |
equalsString | Exact match |
inNumberRange | Range [min, max] |
arrIncludes | Array includes value |
arrIncludesAll | Array includes all |
Set per-column:
{
accessorKey: 'status',
filterFn: 'equalsString',
}Fuzzy Filtering
Uses @tanstack/match-sorter-utils for ranked fuzzy matching:
import { rankItem, compareItems } from '@tanstack/match-sorter-utils';
import type { FilterFn, SortingFn } from '@tanstack/react-table';
// Extend table filter functions type
declare module '@tanstack/react-table' {
interface FilterFns {
fuzzy: FilterFn<unknown>;
}
}
const fuzzyFilter: FilterFn<unknown> = (row, columnId, value, addMeta) => {
const itemRank = rankItem(row.getValue(columnId), value);
addMeta({ itemRank });
return itemRank.passed;
};
// Optional: sort by fuzzy rank
const fuzzySort: SortingFn<unknown> = (rowA, rowB, columnId) => {
let dir = 0;
if (rowA.columnFiltersMeta[columnId]) {
dir = compareItems(
rowA.columnFiltersMeta[columnId]?.itemRank,
rowB.columnFiltersMeta[columnId]?.itemRank,
);
}
return dir === 0 ? sortingFns.alphanumeric(rowA, rowB, columnId) : dir;
};
const table = useReactTable({
data,
columns,
filterFns: { fuzzy: fuzzyFilter },
globalFilterFn: 'fuzzy',
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});Client-Side Faceted Filters
Use faceted row models for local filter option counts:
import {
getFacetedRowModel,
getFacetedUniqueValues,
getFacetedMinMaxValues,
} from '@tanstack/react-table';
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getFacetedMinMaxValues: getFacetedMinMaxValues(),
});
// Get unique values for a column (for building filter dropdowns)
const uniqueValues = column.getFacetedUniqueValues(); // Map<value, count>
// Get min/max for a numeric column (for range filters)
const [min, max] = column.getFacetedMinMaxValues() ?? [0, 0];Server-Side Faceted Filters
Fetch distinct values from the server and use them as filter options:
const getFilterOptions = createServerFn({ method: 'GET' }).handler(async () => {
const [statuses, roles] = await Promise.all([
db.selectDistinct({ status: users.status }).from(users),
db.selectDistinct({ role: users.role }).from(users),
]);
return {
status: statuses.map((s) => s.status),
role: roles.map((r) => r.role),
};
});
function FilterToolbar({ table }: { table: Table<User> }) {
const { data: options } = useQuery({
queryKey: ['users', 'filter-options'],
queryFn: getFilterOptions,
staleTime: 1000 * 60 * 10,
});
return (
<div className="flex gap-2">
{options?.status && (
<FacetedFilter
column={table.getColumn('status')}
title="Status"
options={options.status.map((s) => ({ label: s, value: s }))}
/>
)}
</div>
);
}Column Filters to Server Query
Convert TanStack Table filter state to server-side query parameters:
function buildServerFilters(columnFilters: ColumnFiltersState) {
const filters: Record<string, string | string[]> = {};
for (const filter of columnFilters) {
if (Array.isArray(filter.value)) {
filters[filter.id] = filter.value;
} else {
filters[filter.id] = String(filter.value);
}
}
return filters;
}Debounced Filter Input
Prevent excessive re-renders and API calls when typing:
function DebouncedInput({
value: initialValue,
onChange,
debounce = 300,
label,
...props
}: {
value: string;
onChange: (value: string) => void;
debounce?: number;
label: string;
}) {
const [value, setValue] = useState(initialValue);
useEffect(() => setValue(initialValue), [initialValue]);
useEffect(() => {
const timeout = setTimeout(() => onChange(value), debounce);
return () => clearTimeout(timeout);
}, [value, debounce, onChange]);
return (
<TextField
{...props}
label={label}
value={value}
onChange={(v) => setValue(v)}
/>
);
}Enabling Filters on Columns
const columns = [
{
accessorKey: 'name',
header: 'Name',
enableColumnFilter: true,
},
{
accessorKey: 'id',
header: 'ID',
enableColumnFilter: false, // Disable filtering for this column
},
];Server-Side Filtering
When using server-side filtering, set manualFiltering: true to prevent client-side filtering:
const table = useReactTable({
data: data?.data ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
manualFiltering: true,
state: { columnFilters },
onColumnFiltersChange: setColumnFilters,
});Include filter state in query key so changes trigger refetch:
const { data } = useQuery({
queryKey: ['users', columnFilters],
queryFn: () => fetchUsers({ filters: buildServerFilters(columnFilters) }),
});Infinite Scroll
Server Function with Cursor Pagination
import { createServerFn } from '@tanstack/react-start';
import { db, users, lt, gt, desc, asc } from '@oakoss/database';
const getUsersInfinite = createServerFn({ method: 'GET' })
.inputValidator(
z.object({
cursor: z.string().optional(),
limit: z.number().default(20),
sortBy: z.string().default('createdAt'),
sortOrder: z.enum(['asc', 'desc']).default('desc'),
}),
)
.handler(async ({ data }) => {
const { cursor, limit, sortBy, sortOrder } = data;
const column = users[sortBy as keyof typeof users];
let query = db.select().from(users);
if (cursor) {
query = query.where(
sortOrder === 'desc' ? lt(column, cursor) : gt(column, cursor),
);
}
query = query
.orderBy(sortOrder === 'desc' ? desc(column) : asc(column))
.limit(limit + 1);
const items = await query;
const hasMore = items.length > limit;
const results = hasMore ? items.slice(0, -1) : items;
const nextCursor = hasMore ? results.at(-1)?.[sortBy] : undefined;
return { items: results, nextCursor };
});Infinite Query Options
import { infiniteQueryOptions } from '@tanstack/react-query';
function usersInfiniteOptions() {
return infiniteQueryOptions({
queryKey: ['users', 'infinite'],
queryFn: ({ pageParam }) =>
getUsersInfinite({ data: { cursor: pageParam, limit: 20 } }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
}Infinite Table with Intersection Observer
Uses react-intersection-observer to detect when the user scrolls near the bottom:
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
function InfiniteUsersTable() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } =
useInfiniteQuery(usersInfiniteOptions());
const { ref, inView } = useInView({ threshold: 0 });
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
const flatData = useMemo(
() => data?.pages.flatMap((page) => page.items) ?? [],
[data],
);
const table = useReactTable({
data: flatData,
columns,
getCoreRowModel: getCoreRowModel(),
});
if (isPending) return <TableSkeleton />;
return (
<div className="max-h-[600px] overflow-auto">
<table className="w-full">
<thead className="sticky top-0 bg-background">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="border p-2">
{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} className="border p-2">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
{/* Sentinel element triggers next page fetch */}
<div ref={ref} className="h-20 flex items-center justify-center">
{isFetchingNextPage && <Spinner />}
{!hasNextPage && flatData.length > 0 && (
<p className="text-muted-foreground">No more items</p>
)}
</div>
</div>
);
}Virtual Infinite Scroll
Combine useInfiniteQuery with TanStack Virtual for best performance on large datasets:
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualInfiniteTable() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(usersInfiniteOptions());
const flatData = useMemo(
() => data?.pages.flatMap((page) => page.items) ?? [],
[data],
);
const table = useReactTable({
data: flatData,
columns,
getCoreRowModel: getCoreRowModel(),
});
const { rows } = table.getRowModel();
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 52,
overscan: 10,
});
useEffect(() => {
const lastItem = virtualizer.getVirtualItems().at(-1);
if (!lastItem) return;
if (
lastItem.index >= rows.length - 5 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
}, [
virtualizer.getVirtualItems(),
hasNextPage,
isFetchingNextPage,
rows.length,
fetchNextPage,
]);
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<table className="w-full">
<thead className="sticky top-0 bg-background z-10">
{/* header rendering omitted (see InfiniteUsersTable above) */}
</thead>
{/* display: grid + absolute rows bypass table layout for virtualization */}
<tbody
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
display: 'grid',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<tr
key={row.id}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: 'flex',
}}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="border p-2" style={{ flex: 1 }}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
}Key Points
- Flatten pages:
data.pages.flatMap(page => page.items)— TanStack Table needs a flat array - Cursor strategy: Fetch
limit + 1rows, if extra exists there are more pages - Intersection observer vs virtual scroll: Use observer for simpler cases, virtual scroll for 1000+ total rows
- Memoize flatData: Wrap in
useMemoto prevent unnecessary re-renders
Known Issues
Issue 1: Infinite Re-Renders
- Symptom: Browser freezes, "Maximum update depth exceeded" error
- Cause:
dataorcolumnsreferences change on every render - Fix: Memoize with
useMemoor define outside component
// BAD: New array every render
const data = [{ id: 1 }];
// GOOD: Stable reference
const data = useMemo(() => [{ id: 1 }], []);
// OR: Define outside the componentIssue 2: Query + Table State Mismatch
- Symptom: Changing page doesn't fetch new data, stale data displayed
- Cause: Query key missing table state (pagination, filters, sorting)
- Fix: Include ALL state in query key
// BAD
queryKey: ['users']; // Static!
// GOOD
queryKey: ['users', pagination, sorting, filters];Issue 3: Server-Side Features Not Working
- Symptom: Pagination/filtering/sorting happens client-side instead of server-side
- Cause: Missing
manual*flags - Fix: Set all three flags + provide
pageCount
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualFiltering: true,
manualSorting: true,
pageCount: serverPageCount,
});Issue 4: TypeScript "Cannot Find Module"
- Symptom: Import error for
createColumnHelperor other exports - Fix: Import from
@tanstack/react-table, not@tanstack/table-core
// BAD
import { createColumnHelper } from '@tanstack/table-core';
// GOOD
import { createColumnHelper } from '@tanstack/react-table';Issue 5: Sorting Not Working Server-Side
- Symptom: Clicking sort headers doesn't update data
- Cause: Sorting state not included in query key or API params
- Fix: Include
sortingin query key, add sort params to API, setmanualSorting: true+onSortingChange
Issue 6: Poor Performance (1000+ Rows)
- Symptom: Table slow or laggy with large datasets
- Fix: Use TanStack Virtual for client-side rendering or implement server-side pagination
- Tip: Close React DevTools during benchmarks (see Issue 11)
Issue 7: React Compiler Incompatibility (React 19+)
- Symptom: Table doesn't re-render when data changes (with React Compiler enabled)
- Source: GitHub Issue #5567
- Cause: React Compiler's automatic memoization conflicts with table core instance
- Fix: Add
'use no memo'directive at top of components usinguseReactTable
'use no memo';
function TableComponent() {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
// Now works correctly with React Compiler
}This also affects column visibility and row selection. Full fix coming in v9.
Issue 8: Server-Side Pagination Row Selection Bug
- Symptom:
toggleAllRowsSelected(false)only deselects current page, not all pages - Source: GitHub Issue #5929
- Cause: Selection state persists across pages (intentional), but header checkbox state is calculated incorrectly
- Fix: Manually clear selection state
const toggleAllRows = (value: boolean) => {
if (!value) {
table.setRowSelection({}); // Clear entire selection object
} else {
table.toggleAllRowsSelected(true);
}
};Issue 9: Client-Side onPaginationChange Returns Wrong pageIndex
- Symptom:
onPaginationChangealways returnspageIndex: 0 - Source: GitHub Issue #5970
- Cause: Client-side pagination mode has state tracking bug (works correctly in manual mode)
- Fix: Use manual pagination
const table = useReactTable({
data,
columns,
manualPagination: true,
pageCount: Math.ceil(data.length / pagination.pageSize),
state: { pagination },
onPaginationChange: setPagination,
});Issue 10: Row Selection Not Cleaned Up When Data Removed
- Symptom: Selected rows that no longer exist remain in selection state
- Source: GitHub Issue #5850
- Cause: Intentional for server-side pagination (rows disappear from page but stay selected)
- Fix: Manually clean up selection when removing data
const removeRow = (idToRemove: string) => {
setData(data.filter((row) => row.id !== idToRemove));
const { rowSelection } = table.getState();
if (rowSelection[idToRemove]) {
table.setRowSelection((old) => {
const filtered = Object.entries(old).filter(([id]) => id !== idToRemove);
return Object.fromEntries(filtered);
});
}
};
// OR: Clear all selection
table.resetRowSelection(true);Issue 11: Performance Degradation with React DevTools
- Symptom: Table performance significantly degrades with React DevTools open
- Cause: DevTools inspects table instance and row models on every render (500+ rows)
- Fix: Close React DevTools during performance testing. Not a production issue.
Issue 12: TypeScript getValue() Type with Grouped Columns
- Symptom:
getValue()returnsunknowninstead of accessor's type insidecolumnHelper.group() - Source: GitHub Issue #5860
- Fix: Type assertion or
renderValue()
// Option 1: Type assertion
cell: (info) => {
const value = info.getValue() as string;
return value.toUpperCase();
};
// Option 2: renderValue() (better type inference)
cell: (info) => {
const value = info.renderValue();
return typeof value === 'string' ? value.toUpperCase() : value;
};Issue 13: Column Filter Not Resetting Page
- Symptom: Changing a column filter shows empty results or wrong page
- Cause:
pageIndexstays at current value when filters narrow the result set - Fix: Reset
pageIndexto 0 when filters change
const handleFilterChange = (updater: Updater<ColumnFiltersState>) => {
setColumnFilters(updater);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
};Issue 14: Row Selection State Uses String Keys
- Symptom:
rowSelectionstate keys don't match your data IDs - Cause: TanStack Table uses row index as key by default, not your data's ID field
- Fix: Set
getRowIdto use your data's unique identifier
const table = useReactTable({
data,
columns,
getRowId: (row) => row.id,
enableRowSelection: true,
state: { rowSelection },
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
});Without getRowId, selection breaks across page changes because indices shift.
Issue 15: Column Filter Value Type Mismatch
- Symptom:
column.setFilterValue()doesn't filter correctly, or filter clears unexpectedly - Cause: Filter value type doesn't match what the filter function expects
- Fix: Match value types between
setFilterValueandfilterFn
// For range filters, pass a tuple
column.setFilterValue([min, max]);
// For select filters, pass the exact value type
column.setFilterValue('active'); // string, not { label: 'Active', value: 'active' }
// To clear a filter, pass undefined (not null or empty string)
column.setFilterValue(undefined);Debugging Tips
Diagnosing Re-Render Loops
// Log what's causing re-renders
useEffect(() => {
console.log('data ref changed');
}, [data]);
useEffect(() => {
console.log('columns ref changed');
}, [columns]);
useEffect(() => {
console.log('table state changed', table.getState());
});If data ref changed fires repeatedly, data is not memoized.
Verifying Query Key Sync
const queryKey = ['users', pagination, sorting, filters];
console.log('queryKey:', JSON.stringify(queryKey));Check the Network tab to confirm each state change triggers exactly one API call. Multiple calls indicate unstable references in the query key.
State Handler Debugging
onPaginationChange: (updater) => {
const next =
typeof updater === 'function' ? updater(pagination) : updater;
console.log('pagination:', pagination, '->', next);
setPagination(next);
},Common Debugging Checklist
| Symptom | First Check | Likely Cause |
|---|---|---|
| Table doesn't update | React DevTools: check for re-renders | React Compiler + missing 'use no memo' |
| Filter shows wrong results | Log columnFilters state | Value type mismatch or stale state |
| Selection lost on page change | Check getRowId config | Missing getRowId, using index keys |
| API called multiple times | Network tab: count requests per action | Unstable query key references |
| Empty table after filter | Log pageIndex on filter change | Page not reset to 0 |
| Sort not applying server-side | Log query key after sort click | sorting not in query key |
Reusable Components
Table Skeleton
function TableSkeleton({
rows = 10,
columns = 5,
}: {
rows?: number;
columns?: number;
}) {
return (
<div className="rounded-md border">
<table className="w-full">
<thead>
<tr>
{Array.from({ length: columns }).map((_, i) => (
<th key={i} className="border p-2">
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: rows }).map((_, rowIndex) => (
<tr key={rowIndex}>
{Array.from({ length: columns }).map((_, colIndex) => (
<td key={colIndex} className="border p-2">
<div className="h-4 w-full bg-muted animate-pulse rounded" />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}Loading Overlay
Show over the table while fetching new data (e.g., page change):
function LoadingOverlay() {
return (
<div className="absolute inset-0 flex items-center justify-center bg-background/50 z-10">
<Spinner className="size-8" />
</div>
);
}DataTableColumnHeader
Sortable column header with sort direction indicators and a visibility toggle menu:
import type { Column } from '@tanstack/react-table';
type DataTableColumnHeaderProps<TData, TValue> = {
column: Column<TData, TValue>;
title: string;
};
export function DataTableColumnHeader<TData, TValue>({
column,
title,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) return <div>{title}</div>;
return (
<MenuTrigger>
<Button variant="ghost" size="sm" className="-ml-3 h-8">
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDown className="ml-2 size-4" />
) : column.getIsSorted() === 'asc' ? (
<ArrowUp className="ml-2 size-4" />
) : (
<ChevronsUpDown className="ml-2 size-4" />
)}
</Button>
<Popover>
<Menu>
<MenuItem onAction={() => column.toggleSorting(false)}>
<ArrowUp className="mr-2 size-3.5" /> Asc
</MenuItem>
<MenuItem onAction={() => column.toggleSorting(true)}>
<ArrowDown className="mr-2 size-3.5" /> Desc
</MenuItem>
<MenuItem onAction={() => column.toggleVisibility(false)}>
<EyeOff className="mr-2 size-3.5" /> Hide
</MenuItem>
</Menu>
</Popover>
</MenuTrigger>
);
}DataTablePagination
Full pagination controls with row count, page size selector, and navigation:
import type { Table } from '@tanstack/react-table';
export function DataTablePagination<TData>({ table }: { table: Table<TData> }) {
return (
<div className="flex items-center justify-between px-2">
<div className="text-muted-foreground text-sm">
{table.getFilteredSelectedRowModel().rows.length} of{' '}
{table.getFilteredRowModel().rows.length} selected
</div>
<div className="flex items-center space-x-6">
<div className="flex items-center space-x-2">
<p className="text-sm">Rows per page</p>
<Select
aria-label="Rows per page"
selectedKey={String(table.getState().pagination.pageSize)}
onSelectionChange={(key) => table.setPageSize(Number(key))}
>
{[10, 20, 30, 50].map((size) => (
<SelectItem key={size} id={String(size)}>
{size}
</SelectItem>
))}
</Select>
</div>
<div className="text-sm">
Page {table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
</div>
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onPress={() => table.firstPage()}
isDisabled={!table.getCanPreviousPage()}
>
First
</Button>
<Button
variant="outline"
size="sm"
onPress={() => table.previousPage()}
isDisabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onPress={() => table.nextPage()}
isDisabled={!table.getCanNextPage()}
>
Next
</Button>
<Button
variant="outline"
size="sm"
onPress={() => table.lastPage()}
isDisabled={!table.getCanNextPage()}
>
Last
</Button>
</div>
</div>
</div>
);
}DebouncedInput
Debounced text input for filter/search fields:
function DebouncedInput({
value: initialValue,
onChange,
debounce = 300,
label,
...props
}: {
value: string;
onChange: (value: string) => void;
debounce?: number;
label: string;
}) {
const [value, setValue] = useState(initialValue);
useEffect(() => setValue(initialValue), [initialValue]);
useEffect(() => {
const timeout = setTimeout(() => onChange(value), debounce);
return () => clearTimeout(timeout);
}, [value, debounce, onChange]);
return (
<TextField
{...props}
label={label}
value={value}
onChange={(v) => setValue(v)}
/>
);
}Full-Feature DataTable
Complete table with sorting, filtering, visibility, selection, pagination, and empty state:
function DataTable<TData>({
data,
columns,
}: {
data: TData[];
columns: ColumnDef<TData>[];
}) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [globalFilter, setGlobalFilter] = useState('');
const table = useReactTable({
data,
columns,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
globalFilter,
},
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
onGlobalFilterChange: setGlobalFilter,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full">
<div className="flex items-center gap-2 py-4">
<TextField
label="Search"
value={globalFilter}
onChange={(value) => setGlobalFilter(value)}
className="max-w-sm"
/>
</div>
<div className="rounded-md border">
<table className="w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="border p-2 text-left">
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<tr key={row.id} data-state={row.getIsSelected() && 'selected'}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="border p-2">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</td>
))}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="h-24 text-center">
No results.
</td>
</tr>
)}
</tbody>
</table>
</div>
<DataTablePagination table={table} />
</div>
);
}Important Notes
- All table components must be client components (
'use client') - Use
flexRender()for both static and dynamic content - Access original row data via
row.original - Only import row models you actually use
- Each table is unique — avoid over-abstracting into a single generic wrapper
Server-Side Patterns
When to Use Server-Side
| Condition | Client-Side | Server-Side |
|---|---|---|
| Dataset < 1000 rows | Yes | Optional |
| Dataset > 1000 rows | No | Yes |
| Data changes frequently | No | Yes |
| Need real-time DB queries | No | Yes |
| No backend API | Yes | No |
Pattern 1: Server-Side Pagination
Backend API
export async function onRequestGet({
request,
env,
}: {
request: Request;
env: Env;
}) {
const url = new URL(request.url);
const page = Number(url.searchParams.get('page')) || 0;
const pageSize = Number(url.searchParams.get('pageSize')) || 20;
const offset = page * pageSize;
const { results } = await env.DB.prepare(
'SELECT * FROM users LIMIT ? OFFSET ?',
)
.bind(pageSize, offset)
.all();
const { total } = await env.DB.prepare(
'SELECT COUNT(*) as total FROM users',
).first<{ total: number }>();
return Response.json({
data: results,
pagination: {
page,
pageSize,
total: total ?? 0,
pageCount: Math.ceil((total ?? 0) / pageSize),
},
});
}Frontend
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
});
const { data } = useQuery({
queryKey: ['users', pagination.pageIndex, pagination.pageSize],
queryFn: async () => {
const response = await fetch(
`/api/users?page=${pagination.pageIndex}&pageSize=${pagination.pageSize}`,
);
return response.json();
},
});
const table = useReactTable({
data: data?.data ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount: data?.pagination.pageCount ?? 0,
state: { pagination },
onPaginationChange: setPagination,
});Pattern 2: Combined Pagination + Sorting + Filtering
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 20,
});
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const search =
(columnFilters.find((f) => f.id === 'search')?.value as string) || '';
const { data, isPlaceholderData } = useQuery({
queryKey: ['users', pagination, sorting, search],
queryFn: async () => {
const params = new URLSearchParams({
page: pagination.pageIndex.toString(),
pageSize: pagination.pageSize.toString(),
});
if (sorting.length > 0) {
params.append('sortBy', sorting[0].id);
params.append('sortOrder', sorting[0].desc ? 'desc' : 'asc');
}
if (search) {
params.append('search', search);
}
const response = await fetch(`/api/users?${params}`);
return response.json();
},
placeholderData: (previousData) => previousData,
});
const table = useReactTable({
data: data?.data ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
manualFiltering: true,
pageCount: data?.pagination.pageCount ?? 0,
state: { pagination, sorting, columnFilters },
onPaginationChange: setPagination,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
});Query Key Factory
Structure query keys hierarchically so invalidation works at any level:
const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (pagination: PaginationState, sorting: SortingState, filter: string) =>
[...userKeys.lists(), pagination, sorting, filter] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: string) => [...userKeys.details(), id] as const,
};Then use in queries and invalidation:
const { data } = useQuery({
queryKey: userKeys.list(pagination, sorting, search),
queryFn: () => fetchUsers(pagination, sorting, search),
placeholderData: keepPreviousData,
});
const deleteMutation = useMutation({
mutationFn: deleteUser,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});Query Options Factory
Extract query configuration into a reusable factory function:
import { queryOptions, keepPreviousData } from '@tanstack/react-query';
function usersQueryOptions(
pagination: PaginationState,
sorting: SortingState,
filter: string,
) {
return queryOptions({
queryKey: userKeys.list(pagination, sorting, filter),
queryFn: () =>
getUsers({
data: {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: sorting[0]?.id,
sortOrder: sorting[0]?.desc ? 'desc' : 'asc',
filter,
},
}),
placeholderData: keepPreviousData,
});
}URL Search Params Sync
Sync table state with URL for shareable, bookmarkable links:
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
const searchSchema = z.object({
page: z.number().default(1),
size: z.number().default(10),
sort: z.string().optional(),
order: z.enum(['asc', 'desc']).optional(),
q: z.string().optional(),
});
export const Route = createFileRoute('/users')({
validateSearch: zodValidator(searchSchema),
loaderDeps: ({ search }) => search,
loader: async ({ context, deps }) => {
await context.queryClient.ensureQueryData(
usersQueryOptions(
{ pageIndex: deps.page - 1, pageSize: deps.size },
deps.sort ? [{ id: deps.sort, desc: deps.order === 'desc' }] : [],
deps.q ?? '',
),
);
},
});
function UsersPage() {
const search = Route.useSearch();
const navigate = useNavigate();
const pagination = { pageIndex: search.page - 1, pageSize: search.size };
const sorting = search.sort
? [{ id: search.sort, desc: search.order === 'desc' }]
: [];
const handlePaginationChange = (updater: Updater<PaginationState>) => {
const next = typeof updater === 'function' ? updater(pagination) : updater;
navigate({
search: (prev) => ({
...prev,
page: next.pageIndex + 1,
size: next.pageSize,
}),
});
};
const handleSortingChange = (updater: Updater<SortingState>) => {
const next = typeof updater === 'function' ? updater(sorting) : updater;
navigate({
search: (prev) => ({
...prev,
sort: next[0]?.id,
order: next[0]?.desc ? 'desc' : 'asc',
}),
});
};
// Use these handlers in useReactTable onPaginationChange/onSortingChange
}Optimistic Updates for Mutations
const queryClient = useQueryClient();
const deleteMutation = useMutation({
mutationFn: (id: string) => fetch(`/api/users/${id}`, { method: 'DELETE' }),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['users'] });
const previous = queryClient.getQueryData([
'users',
pagination,
sorting,
search,
]);
queryClient.setQueryData(
['users', pagination, sorting, search],
(old: any) => ({
...old,
data: old.data.filter((user: User) => user.id !== id),
}),
);
return { previous };
},
onError: (_err, _id, context) => {
queryClient.setQueryData(
['users', pagination, sorting, search],
context?.previous,
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});Optimistic Inline Edit
const editMutation = useMutation({
mutationFn: (updated: User) =>
fetch(`/api/users/${updated.id}`, {
method: 'PATCH',
body: JSON.stringify(updated),
}),
onMutate: async (updated) => {
const queryKey = userKeys.list(pagination, sorting, search);
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, (old: UsersResponse) => ({
...old,
data: old.data.map((user) =>
user.id === updated.id ? { ...user, ...updated } : user,
),
}));
return { previous, queryKey };
},
onError: (_err, _updated, context) => {
if (context) {
queryClient.setQueryData(context.queryKey, context.previous);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});Reset Page on Filter Change
When filters change, reset to the first page to avoid empty results:
const handleFilterChange = (updater: Updater<ColumnFiltersState>) => {
setColumnFilters(updater);
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
};
const table = useReactTable({
// ...
onColumnFiltersChange: handleFilterChange,
});Alternatively, use autoResetPageIndex for client-side tables to automatically reset to page 0 when sorting or filtering changes:
const table = useReactTable({
data,
columns,
autoResetPageIndex: true, // Reset page on sort/filter change (default: true)
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});Set autoResetPageIndex: false to preserve the current page when data changes.
Prefetching Next Page
const queryClient = useQueryClient();
useEffect(() => {
if (pagination.pageIndex < (data?.pageCount ?? 0) - 1) {
queryClient.prefetchQuery({
queryKey: ['users', pagination.pageIndex + 1],
queryFn: () => fetchUsers(pagination.pageIndex + 1),
});
}
}, [pagination.pageIndex, data?.pageCount, queryClient]);Backend: Sort Column Validation
Always validate sort columns to prevent SQL injection:
const allowedColumns = ['id', 'name', 'email', 'created_at'];
const allowedOrders = ['asc', 'desc'];
if (!allowedColumns.includes(sortBy) || !allowedOrders.includes(sortOrder)) {
return Response.json({ error: 'Invalid sort parameters' }, { status: 400 });
}Performance Tips
1. Add database indexes for sorted/filtered columns 2. Use `placeholderData` to show old data while fetching 3. Debounce search inputs to reduce API calls; use cursor-based pagination for >100k rows
v7 to v8 Migration
Claude's training data may reference React Table v7. This project uses TanStack Table v8.
Package Name Change
# v7 (old)
npm install react-table
# v8 (new)
npm install @tanstack/react-tableHook Changes
// v7: useTable with plugin hooks
import { useTable, useSortBy, usePagination } from 'react-table';
const { getTableProps, getTableBodyProps, rows } = useTable(
{ columns, data },
useSortBy,
usePagination,
);
// v8: useReactTable with row model functions
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getPaginationRowModel,
} from '@tanstack/react-table';
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});Column Definitions
// v7 columns
const columns = [
{ Header: 'Name', accessor: 'name' },
{ Header: 'Age', accessor: 'age' },
];
// v8 columns (lowercase `header`, `accessorKey`)
const columns: ColumnDef<Person>[] = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'age', header: 'Age' },
];
// v8 with column helper (type-safe)
import { createColumnHelper } from '@tanstack/react-table';
const columnHelper = createColumnHelper<Person>();
const columns = [
columnHelper.accessor('name', {
header: 'Name',
cell: (info) => info.getValue(),
}),
columnHelper.accessor('age', {
header: 'Age',
}),
];Rendering
// v7: Spread props pattern
<table {...getTableProps()}>
<tbody {...getTableBodyProps()}>
{rows.map((row) => (
<tr {...row.getRowProps()}>
{row.cells.map((cell) => (
<td {...cell.getCellProps()}>{cell.render('Cell')}</td>
))}
</tr>
))}
</tbody>
</table>;
// v8: Direct JSX with flexRender
import { flexRender } from '@tanstack/react-table';
<table>
<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>;Sorting State
// v7
const { setSortBy } = useTable(/* ... */);
// v8: Controlled state
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
state: { sorting },
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
});Pagination
// v7
const { pageIndex, pageSize, gotoPage } = useTable(/* ... */);
// v8: Controlled state
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const table = useReactTable({
state: { pagination },
onPaginationChange: setPagination,
getPaginationRowModel: getPaginationRowModel(),
});
// Navigation
table.setPageIndex(0);
table.nextPage();
table.previousPage();Quick Fix Reference
| v7 (old) | v8 (new) |
|---|---|
npm install react-table | npm install @tanstack/react-table |
useTable | useReactTable |
Header: 'Name' | header: 'Name' (lowercase) |
accessor: 'name' | accessorKey: 'name' or columnHelper.accessor('name') |
getTableProps() | Direct JSX (no spread props needed) |
row.cells | row.getVisibleCells() |
cell.render('Cell') | flexRender(cell.column.columnDef.cell, cell.getContext()) |
useSortBy plugin | getSortedRowModel() row model |
usePagination plugin | getPaginationRowModel() row model |
setSortBy | onSortingChange + useState |
gotoPage | table.setPageIndex(n) |
Key Conceptual Changes
1. Plugin hooks → Row models: v7 used plugin hooks (useSortBy, usePagination) passed as arguments. v8 uses row model functions (getSortedRowModel(), getPaginationRowModel()) in the table options.
2. Spread props → Direct JSX: v7 required spreading table/body/row/cell props. v8 uses direct JSX with keys.
3. Uncontrolled → Controlled state: v7 managed state internally. v8 uses explicit controlled state via state + on*Change handlers for full state ownership.
4. `flexRender`: New utility for rendering dynamic cell/header content. Required for both static strings and React components.
Virtualization
When to Virtualize
- Client-side tables with 1000+ rows
- Scrolling feels slow or janky
- Browser runs out of memory
- Need to render 10k+ rows efficiently
For datasets > 10k rows, prefer server-side pagination over client-side virtualization.
Setup
npm install @tanstack/react-virtualBasic Virtualized Table
import { useVirtualizer } from '@tanstack/react-virtual';
import { useReactTable, getCoreRowModel } from '@tanstack/react-table';
import { useRef } from 'react';
function VirtualizedTable({ data, columns }) {
const containerRef = useRef<HTMLDivElement>(null);
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
const { rows } = table.getRowModel();
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 50, // Estimated row height in px
overscan: 10, // Extra rows rendered above/below viewport
});
return (
<div ref={containerRef} style={{ height: '600px', overflow: 'auto' }}>
<table style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<tr
key={row.id}
style={{
position: 'absolute',
transform: `translateY(${virtualRow.start}px)`,
height: `${virtualRow.size}px`,
width: '100%',
}}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
}Hidden Container Warning (Tabs/Modals)
When virtualization is used inside tabbed content or modals that hide inactive content with display: none, the virtualizer continues performing layout calculations while hidden.
Symptoms:
- Infinite re-render loops (especially with 50k+ rows)
- Incorrect scroll position when tab becomes visible
- Empty table or reset scroll position
Source: GitHub Issue #6109
Solutions:
// Option 1: Disable virtualizer when container is hidden
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 50,
overscan: 10,
enabled: containerRef.current?.getClientRects().length !== 0,
});
// Option 2: Conditionally render instead of hiding with CSS
{
isVisible && <VirtualizedTable />;
}Performance Optimization Tips
1. Memoize Data and Columns
// New array every render causes re-renders
const data = useMemo(() => [...rawData], [rawData]);2. Use Fixed Column Sizes
const columns = [
{ accessorKey: 'id', header: 'ID', size: 80 },
{ accessorKey: 'name', header: 'Name', size: 200 },
];3. Memoize Heavy Cell Renderers
const MemoizedCell = React.memo(ExpensiveComponent);
// In column definition
{
accessorKey: 'data',
cell: (info) => <MemoizedCell data={info.getValue()} />,
}4. Enable Row Selection Carefully
Row selection adds overhead — only enable when needed:
enableRowSelection: true, // Only if needed5. Tune Overscan
Higher overscan values give smoother scrolling but render more rows:
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 50,
overscan: 5, // Lower for better performance, higher for smoother scroll
});Memoization Strategy
Memoize at three levels to minimize re-renders:
// Level 1: Stable data reference
const data = useMemo(() => apiResponse?.data ?? [], [apiResponse?.data]);
// Level 2: Stable column definitions (define outside component or useMemo)
const columns = useMemo<ColumnDef<User>[]>(
() => [
{ accessorKey: 'name', header: 'Name', size: 200 },
{ accessorKey: 'email', header: 'Email', size: 250 },
],
[],
);
// Level 3: Memoize expensive cell renderers
const StatusCell = memo(({ status }: { status: string }) => (
<Badge variant={status === 'active' ? 'default' : 'secondary'}>
{status}
</Badge>
));Columns defined inline without useMemo create new references every render, causing the entire table to re-render. This is the most common performance mistake.
Measuring Performance
React Profiler
Wrap the table to measure render cost:
import { Profiler } from 'react';
function onRender(
id: string,
phase: string,
actualDuration: number,
baseDuration: number,
) {
if (actualDuration > 16) {
console.warn(
`${id} ${phase}: ${actualDuration.toFixed(1)}ms (base: ${baseDuration.toFixed(1)}ms)`,
);
}
}
<Profiler id="VirtualTable" onRender={onRender}>
<VirtualizedTable data={data} columns={columns} />
</Profiler>;Key metrics:
actualDuration> 16ms means the render takes longer than one frame (60fps)baseDurationshows the cost without memoization- Compare both to see memoization effectiveness
Performance Checklist
| Check | How to Verify | Target |
|---|---|---|
| Data reference stable | React DevTools highlight updates | No flash on scroll |
| Columns reference stable | Log useMemo deps changes | Zero after mount |
| Cell renderers memoized | Profiler actualDuration | < 16ms per frame |
| Overscan tuned | Scroll smoothness vs DOM node count | 5-15 rows |
| No DevTools during benchmarks | Close React DevTools extension | Required |