# 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) => (
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
))}
))}
{table.getRowModel().rows.map((row) => (
{row.getVisibleCells().map((cell) => (
|
{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/)
| |