Design a reusable Table component with sorting, filtering, pagination, and column resizing
Headless data layer (sort/filter/pagination state) decoupled from rendering. Column config drives header + cell render. Server vs client mode. Use TanStack Table headless under the hood; don't reinvent.
A reusable table is one of the most over-engineered components in any codebase. The trick is separating the data layer from the rendering layer — exactly the model TanStack Table (formerly react-table) ships. A senior answer presents the architecture, not just JSX.
1. Headless data layer. A hook (useTable) accepts columns, data, and state (sort, filters, pageIndex, pageSize) and returns derived rows + helpers. It does no rendering. Consumers pass output to whatever JSX they want — <table>, divs with role="table", or a virtualized list.
2. Column config (the API surface).
type Column<T> = {
id: string;
header: string | ((ctx) => ReactNode);
accessor: (row: T) => unknown;
cell?: (value: unknown, row: T) => ReactNode;
sortable?: boolean;
filterable?: boolean;
width?: number;
align?: "left" | "right";
};This declarative shape means new columns are one config entry, not a JSX edit.
3. Sort / filter / pagination — client vs server mode.
- Client mode: data is the full set; the hook applies sort/filter/slice locally. Right for ≤1k rows.
- Server mode: data is the current page; sort/filter/page state is passed back to the parent which fetches accordingly. Right for ≥10k rows or anywhere the dataset doesn't fit in memory.
A good API supports both via a manualPagination: boolean flag (and similar for sort/filter). TanStack Table does this pattern.
4. Sorting. Tri-state: ascending → descending → none. Multi-column sort with shift-click. Stable sort matters when sorting by a column with ties — preserve original order. For server mode, just emit { id, dir } and let the API handle it.
5. Filtering. Per-column filters (text input in header, dropdown for enums). Global filter (single input filtering across all columns). Debounce text inputs (300ms). For server mode, emit the filter object up; for client mode, intersect predicates.
6. Pagination. Standard { pageIndex, pageSize }. Show "Page 3 of 12, rows 41–60 of 235." Provide page-size selector (10/25/50/100). Reset pageIndex to 0 on filter/sort change.
7. Column resizing. Drag handle on header right edge. Track column widths in state, persist to localStorage if you want them to survive reloads. CSS table-layout: fixed + per-column <col> widths.
8. Selection. selectedIds: Set<string>. Header checkbox toggles all visible. Shift-click range selection. Expose onSelectionChange so the parent can render bulk actions.
9. Accessibility. Use real <table><thead><tbody> (screen readers depend on it). scope="col" on <th>. aria-sort="ascending" on the sorted header. For sortable headers, render as <button> inside the <th> so keyboard users can sort.
10. Virtualize for big tables. Pair with @tanstack/react-virtual over <tbody> rows when you need >500 visible rows. Sticky header still works.
Don't reinvent. Use TanStack Table for the data layer. It's headless, framework-agnostic, ~14KB, and handles every edge case. Build your component as a thin renderer on top.
Code
Follow-up questions
- •How do you switch between client-side and server-side pagination cleanly?
- •How do you implement column resizing without re-rendering every row?
- •How would you add row virtualization?
- •How do you persist column visibility / order across sessions?
Common mistakes
- •Mixing rendering and data logic in one component — every change touches both.
- •Sorting with non-stable algorithms — equal rows shuffle on re-sort.
- •Resetting pageIndex implicitly when data changes — confusing UX.
- •Skipping <table> semantics for divs — breaks screen readers.
Performance considerations
- •Memoize columns and accessor functions; otherwise the hook recomputes every render.
- •Virtualize beyond a few hundred rows.
- •For server mode, debounce filter inputs to avoid request storms.
Edge cases
- •Empty dataset → render an empty state row, not a blank table.
- •Sort by a column whose values include nulls → place nulls last consistently.
- •Column resize during a sort → don't trigger sort on mouseup; check drag distance.
Real-world examples
- •Linear's issue table, GitHub's repo file list, Stripe's dashboard tables — all headless data layer + custom render.