Building Generic, High-Performance Data Tables

Design a fully generic, type-safe React table component that supports sorting, filtering, pagination, row selection, and configurable columns, then scale it to hundreds of thousands of rows using virtualization.

Almost every serious internal tool, admin panel, or dashboard eventually needs a data table: a grid of rows and columns that the user can sort, filter, select, and page through. The naive version — map over an array and render <tr> elements — works fine for 20 rows. It falls apart at 200, and it becomes unusable at 20,000. This lesson builds a table architecture from first principles: a generic TypeScript type for column definitions, a reducer-driven table state (sort/filter/pagination/selection), and finally a virtualized rendering layer so the DOM only ever holds the rows that are actually visible on screen, regardless of how many rows the underlying dataset has.

Why tables need their own architecture

A table is not just a list — it is a list with per-column semantics. Each column has a data type (string, number, date, enum), an accessor (how to read that field off a row), a renderer (how to display it, which may differ from the raw value), and behaviors (is it sortable, is it filterable, what width does it take). If you hardcode all of this as JSX, every new table means copy-pasting a component and editing table cells by hand. The fix is to describe a table declaratively as data — an array of column definitions — and write one generic rendering engine that consumes that description for any row type.

This is where TypeScript generics earn their keep. A table component typed as Table<T> can accept columns typed against T, so that column.accessor is checked against the actual shape of your row data at compile time. Get the field name wrong and TypeScript rejects it before you ever open the browser.

• Column config separates 'what data' from 'how to render it' • Generics let one Table component serve every row shape in the app • Sorting, filtering, pagination and selection are orthogonal concerns — model them as independent, composable state slices

Generic column configuration

A column definition needs: a stable id (used as a React key and as the sort/filter key), a header label, an accessor function that pulls the cell value out of a row, an optional custom cell renderer, and flags for sortability/filterability. Because TypeScript's generics can constrain the accessor's return type to whatever the column needs, you get compile-time safety between the column and the render function.

Keep the column array itself outside the component (module scope or useMemo) so it is referentially stable across renders — this matters once you start memoizing rows for virtualization, since a new columns array on every render would defeat that memoization.

• id: string — stable key, also used as the sort/filter field name • header: string | ReactNode — what shows in the <th> • accessor: (row: T) => value — pure extraction, no side effects • cell?: (value, row) => ReactNode — optional custom rendering • sortable?: boolean, filterable?: boolean — per-column capability flags

State: sorting, filtering, pagination and selection

Table state has four largely independent pieces: sort (which column, which direction), filters (a map of column id to filter value), pagination (current page and page size), and selection (a Set of selected row ids). Bundling all four into one useReducer keeps updates predictable and makes it trivial to reset pagination whenever sort or filters change — a very common bug is forgetting to reset the page index after a filter changes, leaving the user stranded on 'page 5 of 1'.

Selection should be keyed by a stable row id, not by array index — indexes shift when you sort or filter, silently 'moving' a selection onto the wrong row. Always require a getRowId(row): string on the table config.

• sort: { columnId: string; direction: 'asc' | 'desc' } | null • filters: Record<string, string> • pagination: { pageIndex: number; pageSize: number } • selection: Set<string> keyed by getRowId(row), never by array index • Changing sort or filters resets pageIndex to 0

Virtualization: rendering 100,000 rows without dying

The browser cannot cheaply render tens of thousands of DOM nodes — layout and paint cost grows with node count, and scrolling a huge table becomes janky well before 10,000 rows. Virtualization solves this by rendering only the rows currently intersecting the viewport (plus a small overscan buffer above and below), while a spacer element reserves the full scroll height so the scrollbar behaves as if every row were present.

The core math: given a fixed rowHeight, a scrollTop, and a viewport height, you can compute exactly which row indexes are visible with simple division — no need to measure anything. startIndex = floor(scrollTop / rowHeight), and the count of visible rows = ceil(viewportHeight / rowHeight) + overscan on each side. You then render only rows[startIndex..endIndex], absolutely positioned (or translated) at index * rowHeight from the top of an inner container whose total height is rows.length * rowHeight.

This turns an O(n) DOM cost into an O(viewport) DOM cost — the browser only ever has to lay out and paint the ~30-50 rows that fit on screen, whether the dataset has 500 rows or 500,000.

• Outer scroll container: fixed height, overflow: auto • Inner spacer: height = totalRows * rowHeight, creates correct scrollbar size • Rendered window: rows[startIndex..endIndex] positioned with transform: translateY() • Overscan (render a few extra rows above/below viewport) hides pop-in during fast scrolling • Fixed row height keeps the math O(1); variable height needs a measured-height cache instead

Performance beyond virtualization

Virtualization solves DOM node count, but a large table has other performance traps. Re-sorting or re-filtering 100,000 rows on every keystroke will block the main thread — debounce filter input and, for very large datasets, consider doing sort/filter in a Web Worker so typing stays responsive. Memoize row components with React.memo keyed on row id plus the specific cell values they depend on, so a selection change on row 5 doesn't force a re-render of rows 6 through 40. Avoid inline arrow functions inside row renderers when they close over row-specific data that changes every render — hoist a stable onSelect callback keyed by id instead of onClick={() => select(row.id)} created fresh per row per render (React.memo can't help if the prop identity changes every time).

• Debounce or worker-offload sort/filter computation for very large datasets • React.memo row components, comparing only the props that affect that row's render • Stable callback identities (useCallback keyed by row id) so memoization actually holds • Avoid deriving new arrays/objects (e.g. sortedRows.map(...)) inside render without memoizing — recomputes on every parent re-render

Generic column and table type shape

T is the row type — inferred once from the data prop and shared across columns, getRowId, and selection callbacks. accessor's return type constrains what cell receives, keeping renderer and data in sync at compile time.

interface Column<T> {
  id: string;
  header: React.ReactNode;
  accessor: (row: T) => string | number | Date;
  cell?: (value: ReturnType<Column<T>["accessor"]>, row: T) => React.ReactNode;
  sortable?: boolean;
  filterable?: boolean;
  width?: number;
}

interface TableProps<T> {
  data: T[];
  columns: Column<T>[];
  getRowId: (row: T) => string;
  pageSize?: number;
  onSelectionChange?: (ids: Set<string>) => void;
}

Code Examples

One reducer owns all four state concerns. Sorting and filtering both reset pageIndex to 0 so the user is never left on an out-of-range page. Selection is a Set of row ids, immune to row reordering from sort/filter.

pageIndex: 0, // reset pagination whenever the result set can change

Common mistake: Forgetting the pageIndex reset on SORT/FILTER leaves users staring at an empty 'page 6 of 2' after they filter down the data.

Filtering and sorting always run on the full dataset before slicing the current page, otherwise pagination would page through the un-filtered set and show the wrong rows. Wrapping the whole pipeline in useMemo keyed on every state input avoids recomputing filter/sort/paginate on unrelated re-renders (e.g. a parent re-render that doesn't touch table state).

let rows = data; // filter -> sort -> then paginate, always in that order

The outer div is the fixed-size scroll viewport. The inner div is a spacer whose height equals the *full* dataset's height, which gives the scrollbar the correct size and range even though only a handful of rows are actually mounted. Each visible row is absolutely positioned at index * rowHeight, so scrolling never needs to reflow siblings — only the translateY-equivalent top offset changes. Expected result: With rowHeight=40 and 100,000 rows, only ~15-25 DOM row nodes exist at any time, regardless of dataset size.

const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);

Common mistake: Using array index as the React key for *data* rows (not virtualization rows) after a sort/filter reorders them causes React to reuse the wrong DOM node and can show stale cell content or broken focus/selection state — key by getRowId(row) instead.

interface SortState { columnId: string; direction: "asc" | "desc" }

interface TableState {
  sort: SortState | null;
  filters: Record<string, string>;
  pageIndex: number;
  pageSize: number;
  selected: Set<string>;
}

type TableAction<T> =
  | { type: "SORT"; columnId: string }
  | { type: "FILTER"; columnId: string; value: string }
  | { type: "SET_PAGE"; pageIndex: number }
  | { type: "TOGGLE_ROW"; id: string }
  | { type: "TOGGLE_ALL"; ids: string[] };

function tableReducer<T>(state: TableState, action: TableAction<T>): TableState {
  switch (action.type) {
    case "SORT": {
      const isSameColumn = state.sort?.columnId === action.columnId;
      const direction: SortState["direction"] =
        isSameColumn && state.sort!.direction === "asc" ? "desc" : "asc";
      return { ...state, sort: { columnId: action.columnId, direction }, pageIndex: 0 };
    }
    case "FILTER":
      return {
        ...state,
        filters: { ...state.filters, [action.columnId]: action.value },
        pageIndex: 0, // reset pagination whenever the result set can change
      };
    case "SET_PAGE":
      return { ...state, pageIndex: action.pageIndex };
    case "TOGGLE_ROW": {
      cons
…
function useDerivedRows<T>(data: T[], columns: Column<T>[], state: TableState) {
  return useMemo(() => {
    let rows = data;

    // 1. filter — every active filter must match (AND semantics)
    for (const col of columns) {
      const filterValue = state.filters[col.id];
      if (!filterValue) continue;
      rows = rows.filter((row) =>
        String(col.accessor(row)).toLowerCase().includes(filterValue.toLowerCase())
      );
    }

    // 2. sort
    if (state.sort) {
      const col = columns.find((c) => c.id === state.sort!.columnId);
      if (col) {
        const dir = state.sort.direction === "asc" ? 1 : -1;
        rows = [...rows].sort((a, b) => {
          const av = col.accessor(a);
          const bv = col.accessor(b);
          if (av < bv) return -1 * dir;
          if (av > bv) return 1 * dir;
          return 0;
        });
      }
    }

    const total = rows.length;

    // 3. paginate
    const start = state.pageIndex * state.pageSize;
    const pageRows = rows.slice(start, start + state.pageSize);

    return { pageRows, total, pageCount: Math.ceil(total / state.pageSize) };
  }, [data, columns, state.filters, state.sort, state.pageIndex, state.pageSize])
…
interface VirtualizedListProps<T> {
  rows: T[];
  rowHeight: number;
  viewportHeight: number;
  overscan?: number;
  renderRow: (row: T, index: number) => React.ReactNode;
}

function VirtualizedList<T>({
  rows,
  rowHeight,
  viewportHeight,
  overscan = 5,
  renderRow,
}: VirtualizedListProps<T>) {
  const [scrollTop, setScrollTop] = useState(0);

  const totalHeight = rows.length * rowHeight;
  const visibleCount = Math.ceil(viewportHeight / rowHeight);

  const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
  const endIndex = Math.min(rows.length, startIndex + visibleCount + overscan * 2);

  const visibleRows = rows.slice(startIndex, endIndex);

  return (
    <div
      style={{ height: viewportHeight, overflowY: "auto", position: "relative" }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ height: totalHeight, position: "relative" }}>
        {visibleRows.map((row, i) => {
          const index = startIndex + i;
          return (
            <div
              key={index}
              style={{
                position: "absolute",
                top: index * rowHeight,
                left: 0,
        
…

Virtualized table rendering window

Only rows intersecting the viewport (plus overscan) are ever mounted; a spacer element preserves correct scroll geometry.

Full dataset (100,000 rows)                 Scroll viewport (400px, rowHeight=40)
┌─────────────────────────┐
│ row 0                    │  (not rendered)
│ ...                      │
│ row 4,982                │  (not rendered)
├─────────────────────────┤ <- startIndex = floor(scrollTop/rowHeight) - overscan
│ row 4,983  ┐             │
│ row 4,984  │             │
│ row 4,985  │  RENDERED   │   <-- only these ~15-20 rows exist as DOM nodes
│   ...      │  WINDOW     │       absolutely positioned at index*rowHeight
│ row 5,002  │             │
│ row 5,003  ┘             │
├─────────────────────────┤ <- endIndex
│ row 5,004                │  (not rendered)
│ ...                      │
│ row 99,999                │  (not rendered)
└─────────────────────────┘
Inner spacer height = 100,000 * rowHeight  → correct scrollbar size/position

Important Rules

Model column shape as data (Column<T>[]), not as hand-written JSX per table — one generic renderer, many configs.

Always filter, then sort, then paginate, in that order, on the full dataset — never paginate before filtering.

Reset pageIndex to 0 whenever sort or filters change.

Key selection state by a stable row id (getRowId), never by array index.

Virtualize once row count is large enough that mounted DOM nodes noticeably affect scroll/interaction performance (commonly a few hundred rows and up).

Keep the columns array referentially stable (module scope or useMemo) so row memoization isn't invalidated every render.

Where this shows up

Admin dashboards (user lists, order tables), financial trading blotters, log viewers, and spreadsheet-like data grids all need this exact stack: generic typed columns for maintainability, a unified state reducer for predictable sort/filter/paginate/select behavior, and virtualization the moment the dataset stops being 'small.' Libraries like TanStack Table and TanStack Virtual formalize exactly these patterns — understanding them from scratch here makes reading (and debugging) those libraries' internals far easier.

rows.map((row, i) => <tr key={i}>...) rows.map((row) => <tr key={getRowId(row)}>...)

Index-based keys break identity across sorts/filters/inserts, causing React to misattribute DOM state (focus, animation, selection) to the wrong logical row.

const pageRows = data.slice(start, end).filter(matchesFilter).sort(cmp) const pageRows = data.filter(matchesFilter).sort(cmp).slice(start, end)

Slicing before filtering/sorting pages through the wrong subset — you must filter and sort the *entire* dataset first, then take the current page's slice of the result.

<tr onClick={() => selectRow(row.id)}> inside a memoized row component const onClick = useCallback(() => selectRow(row.id), [row.id, selectRow]); pass it as a stable prop

A fresh arrow function every render gives every memoized row a new prop identity every render, defeating React.memo entirely.

In the filter → sort → paginate pipeline, why must pagination happen last? A. Because React requires slice() to be the final array operation in a component B. Because filtering/sorting must operate on the complete result set, otherwise the 'page' shown would be a page of the wrong subset of rows C. Because pagination is always slower than filtering, so it should run first to save time D. It doesn't matter — the three operations are commutative

B. Because filtering/sorting must operate on the complete result set, otherwise the 'page' shown would be a page of the wrong subset of rows — If you slice a page before filtering or sorting, you filter/sort only the rows that happened to land on that page — producing wrong results. Filter and sort must see the whole dataset; pagination is the final windowing step.

Why does the virtualized list render an inner spacer div with height = totalRows * rowHeight? A. To reserve memory for the rows in advance B. To give the browser's native scrollbar the correct size and scroll range, even though most rows aren't actually mounted C. It's required by React's key algorithm D. To prevent layout shift when images load

B. To give the browser's native scrollbar the correct size and scroll range, even though most rows aren't actually mounted — The spacer's height makes the scroll container behave as if all rows were present, producing a correctly sized/positioned scrollbar, while only the rows in the visible window are actually rendered as DOM nodes.

Why should table row selection be keyed by getRowId(row) rather than by array index? A. Array indexes are not valid object keys in JavaScript B. Indexes change when the array is sorted or filtered, which would silently move the 'selected' state onto a different logical row C. getRowId is always faster to compute than using the index D. React requires string keys for Set elements

B. Indexes change when the array is sorted or filtered, which would silently move the 'selected' state onto a different logical row — Sorting or filtering reorders/removes rows, so the row that was at index 3 a moment ago may not be the same row now. A stable id keeps selection attached to the correct logical row regardless of display order.

Extend the VirtualizedList example into a virtualized <table> that also supports multi-column sort (an array of {columnId, direction} instead of a single SortState) and shows a sort-priority badge (1, 2, 3...) next to each active sort column's header.

Store sort as SortState[]; iterate the array in reverse when building the comparator chain, applying [...rows].sort() once per criterion from least to most significant, relying on stable sort to preserve prior ordering for ties.

Sort stability matters: sort by the *last* criterion first, then stable-sort by each preceding criterion in reverse order, so earlier criteria take priority (Array.prototype.sort is required to be stable in modern JS engines).