tanstack-table

Headless data tables with server-side pagination, filtering, sorting, and virtualization for Cloudflare Workers + D1. Supports three rendering modes: client-side with core row model, server-side with manual state management, and virtualized rendering for 1000+ row datasets Includes column/row pinning, row expanding with nested data, and row grouping with built-in aggregation functions (sum, min, max, mean, etc.) Integrates with TanStack Query for coordinated data fetching and state synchronization across pagination, filtering, and sorting Prevents 12 documented errors including infinite re-renders, React 19 Compiler incompatibility, server-side row selection bugs, and virtualization issues in hidden containers

INSTALLATION
npx skills add https://github.com/jezweb/claude-skills --skill tanstack-table
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

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/react-table@8.21.3, @tanstack/react-virtual@3.13.18

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):

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):

// 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):

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:

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

Prevention:

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 &#x26;&#x26; <VirtualizedTable />}

Column/Row Pinning

Pin columns or rows to keep them visible during horizontal/vertical scroll:

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

Prevention:

// 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:

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() &#x26;&#x26; (

                <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):

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:

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) | GitHub Issue #5926

Verified: Community testing + GitHub issue report

Prevention:

// 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 &#x26; 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)
  • 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:
"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
  • 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:
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
  • 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:
// 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
  • 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:
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()
  • Fix: Manually specify type or use renderValue():
// 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.

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card