home / skills / jezweb / claude-skills / tanstack-table
/skills/tanstack-table
This skill helps you build scalable TanStack Table UIs with server-side pagination, filtering, sorting, and virtualization for large datasets.
npx playbooks add skill jezweb/claude-skills --skill tanstack-tableReview the files below or copy the command above to add this skill to your agents.
---
name: tanstack-table
description: |
Build headless data tables with TanStack Table v8. Server-side pagination, filtering, sorting, and virtualization for Cloudflare Workers + D1. Prevents 12 documented errors.
Use when building tables with large datasets, coordinating with TanStack Query, or fixing state management, performance, or React 19+ compatibility issues.
user-invocable: true
---
# TanStack Table
Headless data tables with server-side pagination, filtering, sorting, and virtualization for Cloudflare Workers + D1
---
## Quick Start
**Last Updated**: 2026-01-09
**Versions**: @tanstack/[email protected], @tanstack/[email protected]
```bash
npm install @tanstack/react-table@latest
npm install @tanstack/react-virtual@latest # For virtualization
```
**Basic Setup** (CRITICAL: memoize data/columns to prevent infinite re-renders):
```typescript
import { useReactTable, getCoreRowModel, ColumnDef } from '@tanstack/react-table'
import { useMemo } from 'react'
const columns: ColumnDef<User>[] = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'Email' },
]
function UsersTable() {
const data = useMemo(() => [...users], []) // Stable reference
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() })
return (
<table>
<thead>
{table.getHeaderGroups().map(group => (
<tr key={group.id}>
{group.headers.map(h => <th key={h.id}>{h.column.columnDef.header}</th>)}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => <td key={cell.id}>{cell.renderValue()}</td>)}
</tr>
))}
</tbody>
</table>
)
}
```
---
## Server-Side Patterns
**Cloudflare D1 API** (pagination + filtering + sorting):
```typescript
// Workers API: functions/api/users.ts
export async function onRequestGet({ request, env }) {
const url = new URL(request.url)
const page = Number(url.searchParams.get('page')) || 0
const pageSize = 20
const search = url.searchParams.get('search') || ''
const sortBy = url.searchParams.get('sortBy') || 'created_at'
const sortOrder = url.searchParams.get('sortOrder') || 'DESC'
const { results } = await env.DB.prepare(`
SELECT * FROM users
WHERE name LIKE ? OR email LIKE ?
ORDER BY ${sortBy} ${sortOrder}
LIMIT ? OFFSET ?
`).bind(`%${search}%`, `%${search}%`, pageSize, page * pageSize).all()
const { total } = await env.DB.prepare('SELECT COUNT(*) as total FROM users').first()
return Response.json({
data: results,
pagination: { page, pageSize, total, pageCount: Math.ceil(total / pageSize) },
})
}
```
**Client-Side** (TanStack Query + Table):
```typescript
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 20 })
const [columnFilters, setColumnFilters] = useState([])
const [sorting, setSorting] = useState([])
// CRITICAL: Include ALL state in query key
const { data, isLoading } = useQuery({
queryKey: ['users', pagination, columnFilters, sorting],
queryFn: async () => {
const params = new URLSearchParams({
page: pagination.pageIndex,
search: columnFilters.find(f => f.id === 'search')?.value || '',
sortBy: sorting[0]?.id || 'created_at',
sortOrder: sorting[0]?.desc ? 'DESC' : 'ASC',
})
return fetch(`/api/users?${params}`).then(r => r.json())
},
})
const table = useReactTable({
data: data?.data ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
// CRITICAL: manual* flags tell table server handles these
manualPagination: true,
manualFiltering: true,
manualSorting: true,
pageCount: data?.pagination.pageCount ?? 0,
state: { pagination, columnFilters, sorting },
onPaginationChange: setPagination,
onColumnFiltersChange: setColumnFilters,
onSortingChange: setSorting,
})
```
---
## Virtualization (1000+ Rows)
Render only visible rows for performance:
```typescript
import { useVirtualizer } from '@tanstack/react-virtual'
function VirtualizedTable() {
const containerRef = useRef<HTMLDivElement>(null)
const table = useReactTable({ data: largeDataset, columns, getCoreRowModel: getCoreRowModel() })
const { rows } = table.getRowModel()
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 50, // Row height px
overscan: 10,
})
return (
<div ref={containerRef} style={{ height: '600px', overflow: 'auto' }}>
<table style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
<tbody>
{rowVirtualizer.getVirtualItems().map(virtualRow => {
const row = rows[virtualRow.index]
return (
<tr key={row.id} style={{ position: 'absolute', transform: `translateY(${virtualRow.start}px)` }}>
{row.getVisibleCells().map(cell => <td key={cell.id}>{cell.renderValue()}</td>)}
</tr>
)
})}
</tbody>
</table>
</div>
)
}
```
### Warning: Hidden Containers (Tabs/Modals)
**Known Issue**: When using virtualization inside tabbed content or modals that hide inactive content with `display: none`, the virtualizer continues performing layout calculations while hidden, causing:
- Infinite re-render loops (large datasets: 50k+ rows)
- Incorrect scroll position when tab becomes visible
- Empty table or reset scroll (small datasets)
**Source**: [GitHub Issue #6109](https://github.com/TanStack/table/issues/6109)
**Prevention**:
```typescript
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => containerRef.current,
estimateSize: () => 50,
overscan: 10,
// Disable when container is hidden to prevent infinite re-renders
enabled: containerRef.current?.getClientRects().length !== 0,
})
// OR: Conditionally render instead of hiding with CSS
{isVisible && <VirtualizedTable />}
```
---
## Column/Row Pinning
Pin columns or rows to keep them visible during horizontal/vertical scroll:
```typescript
import { useReactTable, getCoreRowModel } from '@tanstack/react-table'
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
// Enable pinning
enableColumnPinning: true,
enableRowPinning: true,
// Initial pinning state
initialState: {
columnPinning: {
left: ['select', 'name'], // Pin to left
right: ['actions'], // Pin to right
},
},
})
// Render with pinned columns
function PinnedTable() {
return (
<div className="flex">
{/* Left pinned columns */}
<div className="sticky left-0 bg-background z-10">
{table.getLeftHeaderGroups().map(/* render left headers */)}
{table.getRowModel().rows.map(row => (
<tr>{row.getLeftVisibleCells().map(/* render cells */)}</tr>
))}
</div>
{/* Center scrollable columns */}
<div className="overflow-x-auto">
{table.getCenterHeaderGroups().map(/* render center headers */)}
{table.getRowModel().rows.map(row => (
<tr>{row.getCenterVisibleCells().map(/* render cells */)}</tr>
))}
</div>
{/* Right pinned columns */}
<div className="sticky right-0 bg-background z-10">
{table.getRightHeaderGroups().map(/* render right headers */)}
{table.getRowModel().rows.map(row => (
<tr>{row.getRightVisibleCells().map(/* render cells */)}</tr>
))}
</div>
</div>
)
}
// Toggle pinning programmatically
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
```
### Warning: Column Pinning with Column Groups
**Known Issue**: 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](https://github.com/TanStack/table/issues/5397)
**Prevention**:
```typescript
// Disable pinning for grouped columns
const isPinnable = (column) => !column.parent
// OR: Pin individual columns within group, not the group itself
table.getColumn('firstName')?.pin('left')
table.getColumn('lastName')?.pin('left')
// Don't pin the parent group column
```
---
## Row Expanding (Nested Data)
Show/hide child rows or additional details:
```typescript
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(), // Required for expanding
getSubRows: row => row.subRows, // Tell table where children are
})
// Render with expand button
function ExpandableTable() {
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` }}>
{cell.renderValue()}
</td>
))}
</tr>
</>
))}
</tbody>
)
}
// Control expansion programmatically
table.toggleAllRowsExpanded() // Expand/collapse all
row.toggleExpanded() // Toggle single row
table.getIsAllRowsExpanded() // Check if all expanded
```
**Detail Rows** (custom content, not nested data):
```typescript
function DetailRow({ row }) {
if (!row.getIsExpanded()) return null
return (
<tr>
<td colSpan={columns.length}>
<div className="p-4 bg-muted">
Custom detail content for row {row.id}
</div>
</td>
</tr>
)
}
```
---
## Row Grouping
Group rows by column values:
```typescript
import { useReactTable, getCoreRowModel, getGroupedRowModel } from '@tanstack/react-table'
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getGroupedRowModel: getGroupedRowModel(), // Required for grouping
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', // Sum grouped values
aggregatedCell: ({ getValue }) => `Total: ${getValue()}`,
},
]
// Render grouped table
function GroupedTable() {
return (
<tbody>
{table.getRowModel().rows.map(row => (
<tr key={row.id}>
{row.getVisibleCells().map(cell => (
<td key={cell.id}>
{cell.getIsGrouped() ? (
// Grouped cell - show group header with expand toggle
<button onClick={row.getToggleExpandedHandler()}>
{row.getIsExpanded() ? '▼' : '▶'} {cell.renderValue()} ({row.subRows.length})
</button>
) : cell.getIsAggregated() ? (
// Aggregated cell - show aggregation result
cell.renderValue()
) : cell.getIsPlaceholder() ? null : (
// Regular cell
cell.renderValue()
)}
</td>
))}
</tr>
))}
</tbody>
)
}
// Built-in aggregation functions
// 'sum', 'min', 'max', 'extent', 'mean', 'median', 'unique', 'uniqueCount', 'count'
```
### Warning: Performance Bottleneck with Grouping (Community-sourced)
**Known Issue**: The grouping feature 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)](https://jpcamara.com/2023/03/07/making-tanstack-table.html) | [GitHub Issue #5926](https://github.com/TanStack/table/issues/5926)
**Verified**: Community testing + GitHub issue report
**Prevention**:
```typescript
// 1. Use server-side grouping for large datasets
// 2. Implement pagination to limit rows per page
// 3. Disable grouping for 10k+ rows
const shouldEnableGrouping = data.length < 10000
// 4. OR: Use React.memo on row components
const MemoizedRow = React.memo(TableRow)
```
---
## Known Issues & Solutions
**Issue #1: Infinite Re-Renders**
- **Error**: Table re-renders infinitely, browser freezes
- **Cause**: `data` or `columns` references change on every render
- **Fix**: Use `useMemo(() => [...], [])` or define data/columns outside component
**Issue #2: Query + Table State Mismatch**
- **Error**: Query refetches but pagination state not synced, stale data
- **Cause**: Query key missing table state (pagination, filters, sorting)
- **Fix**: Include ALL state in query key: `queryKey: ['users', pagination, columnFilters, sorting]`
**Issue #3: Server-Side Features Not Working**
- **Error**: Pagination/filtering/sorting doesn't trigger API calls
- **Cause**: Missing `manual*` flags
- **Fix**: Set `manualPagination: true`, `manualFiltering: true`, `manualSorting: true` + provide `pageCount`
**Issue #4: TypeScript "Cannot Find Module"**
- **Error**: Import errors for `createColumnHelper`
- **Fix**: Import from `@tanstack/react-table` (NOT `@tanstack/table-core`)
**Issue #5: Sorting Not Working Server-Side**
- **Error**: Clicking sort headers doesn't update data
- **Cause**: Sorting state not in query key/API params
- **Fix**: Include `sorting` in query key, add sort params to API call, set `manualSorting: true` + `onSortingChange`
**Issue #6: Poor Performance (1000+ Rows)**
- **Error**: Table slow/laggy with large datasets
- **Fix**: Use TanStack Virtual for client-side OR implement server-side pagination
**Issue #7: React Compiler Incompatibility (React 19+)**
- **Error**: `"Table doesn't re-render when data changes"` (with React Compiler enabled)
- **Source**: [GitHub Issue #5567](https://github.com/TanStack/table/issues/5567)
- **Why It Happens**: React Compiler's automatic memoization conflicts with table core instance, preventing re-renders when data/state changes
- **Prevention**: Add `"use no memo"` directive at top of components using `useReactTable`:
```typescript
"use no memo"
function TableComponent() {
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() })
// Now works correctly with React Compiler
}
```
**Note**: This issue also affects column visibility and row selection. Full fix coming in v9.
**Issue #8: Server-Side Pagination Row Selection Bug**
- **Error**: `toggleAllRowsSelected(false)` only deselects current page, not all pages
- **Source**: [GitHub Issue #5929](https://github.com/TanStack/table/issues/5929)
- **Why It Happens**: Selection state persists across pages (intentional for server-side use cases), but header checkbox state is calculated incorrectly
- **Prevention**: Manually clear selection state when toggling off:
```typescript
const toggleAllRows = (value: boolean) => {
if (!value) {
table.setRowSelection({}) // Clear entire selection object
} else {
table.toggleAllRowsSelected(true)
}
}
```
**Issue #9: Client-Side onPaginationChange Returns Incorrect pageIndex**
- **Error**: `onPaginationChange` always returns `pageIndex: 0` instead of current page
- **Source**: [GitHub Issue #5970](https://github.com/TanStack/table/issues/5970)
- **Why It Happens**: Client-side pagination mode has state tracking bug (only occurs in client mode, works correctly in server/manual mode)
- **Prevention**: Switch to manual pagination for correct behavior:
```typescript
// Instead of relying on client-side pagination
const table = useReactTable({
data,
columns,
manualPagination: true, // Forces correct state tracking
pageCount: Math.ceil(data.length / pagination.pageSize),
state: { pagination },
onPaginationChange: setPagination,
})
```
**Issue #10: Row Selection Not Cleaned Up When Data Removed**
- **Error**: Selected rows that no longer exist in data remain in selection state
- **Source**: [GitHub Issue #5850](https://github.com/TanStack/table/issues/5850)
- **Why It Happens**: Intentional behavior to support server-side pagination (where rows disappear from current page but should stay selected)
- **Prevention**: Manually clean up selection when removing data:
```typescript
const removeRow = (idToRemove: string) => {
// Remove from data
setData(data.filter(row => row.id !== idToRemove))
// Clean up selection if it was selected
const { rowSelection } = table.getState()
if (rowSelection[idToRemove]) {
table.setRowSelection((old) => {
const filtered = Object.entries(old).filter(([id]) => id !== idToRemove)
return Object.fromEntries(filtered)
})
}
}
// OR: Use table.resetRowSelection(true) to clear all
```
**Issue #11: Performance Degradation with React DevTools Open**
- **Error**: Table performance significantly degrades with React DevTools open (development only)
- **Why It Happens**: DevTools inspects table instance and row models on every render, especially noticeable with 500+ rows
- **Fix**: Close React DevTools during performance testing. This is not a production issue.
**Issue #12: TypeScript getValue() Type Inference with Grouped Columns**
- **Error**: `getValue()` returns `unknown` instead of accessor's actual type inside `columnHelper.group()`
- **Source**: [GitHub Issue #5860](https://github.com/TanStack/table/issues/5860)
- **Fix**: Manually specify type or use `renderValue()`:
```typescript
// Option 1: Type assertion
cell: (info) => {
const value = info.getValue() as string
return value.toUpperCase()
}
// Option 2: Use renderValue() (better type inference)
cell: (info) => {
const value = info.renderValue()
return typeof value === 'string' ? value.toUpperCase() : value
}
```
---
**Related Skills**: tanstack-query (data fetching), cloudflare-d1 (database backend), tailwind-v4-shadcn (UI styling)
---
**Last verified**: 2026-01-21 | **Skill version**: 2.0.0 | **Changes**: Added 7 new known issues from TIER 1-2 research findings (React 19 Compiler, server-side row selection, virtualization in hidden containers, client-side pagination bug, column pinning with groups, row selection cleanup, DevTools performance, TypeScript getValue). Error count: 6 → 12.
This skill provides a ready-to-use TypeScript integration for building headless data tables with TanStack Table v8, optimized for Cloudflare Workers + D1. It bundles server-side pagination, filtering, sorting, virtualization, pinning, grouping, and row expansion patterns while preventing 12 documented errors and known edge cases. Use it to deliver high-performance tables with predictable state management and React 18/19+ compatibility. The guidance focuses on practical, production-safe patterns and warnings.
The skill documents concrete client and server patterns: a Workers API that queries D1 with search, pagination, and sorting parameters, and a React client that combines TanStack Table with TanStack Query. It enforces stable data/column references, manual server-side flags, and full table state in query keys so server operations and UI state stay synchronized. For large datasets it shows virtualization via @tanstack/react-virtual and explains disabling virtualization when containers are hidden. It also covers pinning, grouping, expanding rows, and workarounds for group pinning and grouping performance issues.
Why does the table sometimes re-render infinitely?
Most often because data or columns are recreated each render. Memoize them with useMemo or define them outside the component to keep references stable.
How do I keep server sorting and pagination in sync with the table?
Put pagination, columnFilters, and sorting into your queryKey and set manualPagination/manualFiltering/manualSorting; include those values in the API request params.
Why does virtualization misbehave in tabs or modals?
If the scroll container is hidden with display:none the virtualizer can produce layout loops and wrong scroll positions. Disable virtualization when hidden or conditionally render the virtualized table only when visible.