Back to System Design
System Design
hard
mid

How would you design a reusable Table component with sorting, filtering, pagination, and column resizing?

Headless core (state + plugins) + thin UI shell. Composable APIs for column defs, sort, filter, pagination, selection, resizing. Server-side modes for big data. Virtualization for long lists. Adopt TanStack Table for the engine; build the design-system-styled UI on top. Avoid a god-component with 60 props.

5 min read·~25 min to think through

Tables are the most "request a feature" component in any DS. Every team needs sorting, filtering, pagination, resizing, selection, expansion, sticky columns, virtualization, server-side modes, CSV export… A single <Table> with 60 props collapses under its own weight.

The right shape: headless core + UI layer

  • Headless engine owns state + behavior. Doesn't render anything.
  • UI layer renders rows/cells with your DS primitives.

This is exactly what TanStack Table does — and unless you have a strong reason, adopt it instead of building.

Column definitions

tsx
const columns = [
  { accessorKey: "name", header: "Name", cell: (info) => info.getValue(), enableSorting: true },
  { accessorKey: "createdAt", header: "Created", cell: (info) => formatDate(info.getValue()) },
  { id: "actions", header: "", cell: ({ row }) => <RowMenu row={row} /> },
];

Each column is a declarative object. Custom cell renderers receive context (row, cell, table).

Feature surface

FeatureDefault?Server-side mode?
SortingYesYes — manualSorting
FilteringYesYes — manualFiltering
PaginationYesYes — manualPagination
Row selectionYesn/a
Column resizingYesn/a
Expansion / nested rowsOptionaln/a
Sticky header / first columnUI concernn/a
VirtualizationOptional (long lists)n/a

State ownership

  • Controlled: parent owns sorting/filter/pagination state. Required for URL syncing + server-side modes.
  • Uncontrolled (initial state) for simple in-memory tables.

Server-side mode

For big datasets, the table can't filter/sort/paginate client-side — switch to manual flags and have the parent fetch:

tsx
const { data } = useQuery(["users", sorting, columnFilters, pageIndex],
  () => fetchUsers({ sorting, columnFilters, pageIndex }));

Virtualization

For 10k+ rows, render only visible rows with TanStack Virtual or react-window. Without virtualization, scrolling falls off a cliff around 1–5k rows depending on cell complexity.

Accessibility

  • Semantic <table> (not divs) — assistive tech relies on row/column relationships.
  • <th scope="col"> and scope="row" for headers.
  • Sortable headers as <button> inside <th> with aria-sort="ascending|descending|none".
  • Selection: role="row" with aria-selected, plus selection checkboxes labeled.
  • Keyboard: arrow keys for grid navigation on data-grids (role="grid") — but only adopt if you implement it correctly.

Anti-patterns

  • One mega <Table> with props for every feature — composability dies.
  • Re-rendering the whole table on every cell change.
  • DIY virtualization when libraries exist.
  • Building a server-side and client-side variant as separate components.

What to expose

tsx
<Table
  columns={columns}
  data={data}
  state={{ sorting, columnFilters, pagination }}
  onStateChange={...}
  manualSorting
  manualPagination
>
  {/* slots: header, body, empty, loading, footer */}
</Table>

Slots for empty / loading / footer keep the component composable.

Interview framing

"Build on a headless engine like TanStack Table — it owns state for sorting, filtering, pagination, selection, expansion. The UI layer is yours, themed by the design system. Support controlled state so parents can URL-sync or run server-side modes. Virtualize for long lists. Use semantic <table> elements with proper ARIA. Avoid the god-component anti-pattern — expose slots and let consumers compose. The hardest engineering work is server-side modes, virtualization, and a11y — not the rendering."

Follow-up questions

  • How do you implement column resizing without thrashing layout?
  • How would you persist column order + visibility?
  • Walk me through virtualization tradeoffs.

Common mistakes

  • Single component with 60 props.
  • Client-side sorting on 100k rows.
  • Re-rendering all rows on a single cell update.
  • Using divs instead of <table>.

Performance considerations

  • Virtualize for long lists. Memoize row renderers. Avoid re-creating column defs on every render. For column resize, use CSS transform / debounced layout.

Edge cases

  • Mixed sticky header + sticky first column.
  • Selection across pages in server-side mode.
  • Variable row height + virtualization.
  • RTL languages.

Real-world examples

  • TanStack Table, AG Grid, Material React Table, Linear's issue list, Notion's database views.

Senior engineer discussion

Seniors push for headless cores, server-side modes for real datasets, accessible grid roles, and persisted state (URL or storage). They identify when to build vs adopt — and the answer for tables is almost always 'adopt the engine, style your own UI'.

Related questions