Skip to content
fearchitect
UI System-Design Patterns

Data Table / Data Grid

Headless table logic separate from rendering, with server-side ops for large datasets.

By Abas TurabliReviewed

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 angle

A 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

DimensionClient-sideServer-side
When to use< ~10 k rows fit in memory> 10 k rows or slow initial load
Sort / filterTanStack Table built-in; instant feedbackAPI query param; round-trip per change
Pagination`getPaginationRowModel()` slices local arrayBackend returns one page; cursor or offset
Initial data loadFull dataset fetched onceOnly current page — fast first paint
ComplexitySimple; no API contract for table stateMust encode sort/filter/page in query params
Stale data riskData ages until next fetchEach 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.

TanStack Table v8 — server-side sort + paginationtsx
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.

Further reading

Search fearchitect

Jump to a topic, mode, or action.