# Data Table / Data Grid > Headless table logic separate from rendering, with server-side ops for large datasets. Frontend architecture guidance from fearchitect, written by Abas Turabli and last reviewed 2026-06-21. Source: https://fearchitect.com/topics/data-table Use it as reference for the task at hand. Before changing code, check this guidance against the codebase: where the code already makes a different, deliberate choice, flag the conflict instead of rewriting it. Library APIs move faster than this guide, so confirm exact signatures in the official docs linked at the end. ## Summary A data table separates headless logic (sorting, filtering, pagination, selection) from DOM rendering. TanStack Table v8 is the standard headless library. When row counts exceed a few thousand, push sorting, filtering, and pagination to the server and virtualize the visible rows with TanStack Virtual to avoid rendering thousands of DOM nodes. ## Definition A data table displays, sorts, filters, and paginates structured row/column data. The hard part is not the `` element — it's the state machine behind it. TanStack Table v8 is a headless library: call `useReactTable({ data, columns, getCoreRowModel })` and it returns a table model. You map that model to whatever markup you need — semantic HTML, a CSS grid, or canvas. The library never touches the DOM. The key architecture decision: **where do sort, filter, and pagination run?** Client-side works fine under ~10,000 rows in memory. Above that, push those operations to the server and fetch only the current page. ## Client-side vs server-side table operations | Dimension | Client-side | Server-side | | --- | --- | --- | | **When to use** | < ~10 k rows fit in memory | > 10 k rows or slow initial load | | **Sort / filter** | TanStack Table built-in; instant feedback | API query param; round-trip per change | | **Pagination** | `getPaginationRowModel()` slices local array | Backend returns one page; cursor or offset | | **Initial data load** | Full dataset fetched once | Only current page — fast first paint | | **Complexity** | Simple; no API contract for table state | Must encode sort/filter/page in query params | | **Stale data risk** | Data ages until next fetch | Each page fetch reflects current server state | ## TanStack Table v8 — server-side sort + pagination Pass `manualSorting` and `manualPagination` to opt out of client-side logic. The table state becomes the source of truth for your API query. ```tsx import { useReactTable, getCoreRowModel, flexRender, type ColumnDef, type SortingState, type PaginationState, } from "@tanstack/react-table"; import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; type User = { id: string; name: string; email: string; createdAt: string }; const columns: ColumnDef[] = [ { accessorKey: "name", header: "Name", enableSorting: true }, { accessorKey: "email", header: "Email", enableSorting: false }, { accessorKey: "createdAt", header: "Created", enableSorting: true }, ]; export function UserTable() { const [sorting, setSorting] = useState([]); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 }); const { data } = useQuery({ queryKey: ["users", sorting, pagination], queryFn: () => fetch( `/api/users?page=${pagination.pageIndex}&size=${pagination.pageSize}` + (sorting[0] ? `&sort=${sorting[0].id}&dir=${sorting[0].desc ? "desc" : "asc"}` : "") ).then((r) => r.json()) as Promise<{ rows: User[]; total: number }>, }); const table = useReactTable({ data: data?.rows ?? [], columns, rowCount: data?.total ?? 0, state: { sorting, pagination }, onSortingChange: setSorting, onPaginationChange: setPagination, manualSorting: true, // tell TanStack Table not to sort locally manualPagination: true, // tell TanStack Table not to slice locally getCoreRowModel: getCoreRowModel(), }); return (
{table.getHeaderGroups().map((hg) => ( {hg.headers.map((header) => ( ))} ))} {table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( ))} ))}
{flexRender(header.column.columnDef.header, header.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
); } ``` `manualSorting` and `manualPagination` disable TanStack Table's local transforms. State flows to the query key, so TanStack Query refetches automatically when sort or page changes. `aria-sort` on each `` exposes sort state to screen readers. ## Column features: resize, reorder, pin - Enable `enableColumnResizing` and `columnResizeMode: 'onChange'` for live drag-to-resize. - Column reorder: TanStack Table tracks `columnOrder` state; pair with a drag library (dnd-kit). - Pin left/right with `column.pin('left')` — sets `position: sticky` and a `left` offset you compute from pinned widths. - Sticky header: CSS `position: sticky; top: 0` on `` — no JS required. - Row selection uses `getToggleRowSelectedHandler()` per row and a header checkbox via `table.getToggleAllRowsSelectedHandler()`. ## Watch out: Virtualize before you hit 500 visible rows Rendering 1,000+ `` nodes blocks the main thread on every sort or scroll. Use TanStack Virtual (`useVirtualizer`) to mount only the ~20 rows visible in the scroll container. Set a fixed `estimateSize` per row for best performance. Pair with server-side pagination: virtualizing 50,000 rows in memory is still 50,000 JS objects — keep the dataset bounded. ## Note: Accessibility checklist Use a semantic `` with `/`. Set `aria-sort` on every sortable `
` (not just the active column — inactive sortable columns get `none`). Make the table focusable and support arrow-key navigation between cells if the grid is interactive. Add `role="grid"` when cells contain interactive controls. ## Key terms - **headless table**: Library that manages table state (sort, filter, pagination) with no DOM output — you render the markup. - **row virtualization**: Render only the rows in the visible viewport; recycle DOM nodes as the user scrolls. - **server-side operations**: Sorting, filtering, and pagination executed by the backend; only the current page is sent to the client. - **column pinning**: Sticky columns fixed to the left or right edge while the rest scroll horizontally. - **aria-sort**: ARIA attribute on `` communicating sort direction (`ascending`, `descending`, `none`) to screen readers. ## Related topics - [Infinite Scroll & Feeds](https://fearchitect.com/topics/infinite-scroll-feed.md): Cursor-paginated feed with a bounded DOM and accessible fallback. - [Realtime Dashboard](https://fearchitect.com/topics/realtime-dashboard.md): Design a live data dashboard without overwhelming the main thread. - [Server State & Data Fetching](https://fearchitect.com/topics/server-state-data-fetching.md): Async, shared, remote-owned data that requires a dedicated cache layer. - [Render Performance](https://fearchitect.com/topics/render-performance-patterns.md): Skip renders, defer slow work, and virtualize long lists. - [Autocomplete / Typeahead](https://fearchitect.com/topics/autocomplete-typeahead.md): Debounced input, AbortController cancellation, and ARIA combobox. - [File Upload](https://fearchitect.com/topics/file-upload.md): Presigned URLs, chunked uploads, progress UI, and retry. ## Further reading - [TanStack Table v8 — Overview](https://tanstack.com/table/latest/docs/introduction) - [TanStack Table — Server-Side Pagination](https://tanstack.com/table/latest/docs/guide/pagination#manual-server-side-pagination) - [TanStack Virtual — Row Virtualizer](https://tanstack.com/virtual/latest/docs/introduction) - [WAI-ARIA — Grid and Table Properties](https://www.w3.org/WAI/ARIA/apg/patterns/grid/)