Back to Machine Coding
Machine Coding
hard
senior

How would you 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.

9 min read·~50 min to think through

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).

ts
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

tsx
import { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, flexRender } from "@tanstack/react-table";

export function DataTable<T>({ data, columns }: { data: T[]; columns: ColumnDef<T>[] }) {
  const table = useReactTable({
    data, columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  });

  return (
    <table>
      <thead>
        {table.getHeaderGroups().map(hg => (
          <tr key={hg.id}>
            {hg.headers.map(h => (
              <th key={h.id} scope="col" aria-sort={h.column.getIsSorted() ? (h.column.getIsSorted() === "asc" ? "ascending" : "descending") : "none"}>
                <button onClick={h.column.getToggleSortingHandler()}>
                  {flexRender(h.column.columnDef.header, h.getContext())}
                </button>
              </th>
            ))}
          </tr>
        ))}
      </thead>
      <tbody>
        {table.getRowModel().rows.map(row => (
          <tr key={row.id}>{row.getVisibleCells().map(c => <td key={c.id}>{flexRender(c.column.columnDef.cell, c.getContext())}</td>)}</tr>
        ))}
      </tbody>
    </table>
  );
}
Sketch of a TanStack-Table-backed reusable Table

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.

Senior engineer discussion

Senior signal: separating data from render, knowing TanStack Table exists, articulating client-vs-server mode, and handling a11y + virtualization concerns.

Related questions