How would you design a reusable table component with features like 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.
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
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
| Feature | Default? | Server-side mode? |
|---|---|---|
| Sorting | Yes | Yes — manualSorting |
| Filtering | Yes | Yes — manualFiltering |
| Pagination | Yes | Yes — manualPagination |
| Row selection | Yes | n/a |
| Column resizing | Yes | n/a |
| Expansion / nested rows | Optional | n/a |
| Sticky header / first column | UI concern | n/a |
| Virtualization | Optional (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:
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">andscope="row"for headers.- Sortable headers as
<button>inside<th>witharia-sort="ascending|descending|none". - Selection:
role="row"witharia-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
<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.