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.
Jump to the interview angleA data table displays, sorts, filters, and paginates structured row/column data. The hard part is not the <table> 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.
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<User>[] = [
{ 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<SortingState>([]);
const [pagination, setPagination] = useState<PaginationState>({ 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>
<thead>
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((header) => (
<th
key={header.id}
aria-sort={
header.column.getIsSorted() === "asc"
? "ascending"
: header.column.getIsSorted() === "desc"
? "descending"
: "none"
}
onClick={header.column.getToggleSortingHandler()}
style={{ cursor: header.column.getCanSort() ? "pointer" : "default" }}
>
{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>
);
}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 <th> 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 `<thead>` — no JS required.
- Row selection uses `getToggleRowSelectedHandler()` per row and a header checkbox via `table.getToggleAllRowsSelectedHandler()`.
Virtualize before you hit 500 visible rows
Rendering 1,000+ <tr> 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.
Accessibility checklist
Use a semantic <table> with <thead>/<tbody>. Set aria-sort on every sortable <th> (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.
Interview angle
Interviewers test whether you can draw the headless-vs-rendering split, explain when client-side operations break down, and describe virtualization and accessibility requirements. Cover the client-vs-server threshold, aria-sort, and keyboard navigation.
Soundbite: "TanStack Table owns the state; you own the markup — push ops to the server once the client can't sort in memory fast enough."
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 `<th>` communicating sort direction (`ascending`, `descending`, `none`) to screen readers.