ESC

Type to search the knowledge base.

Sortable Data Table

Machine-coding brief for a sortable data table — column sort state, a11y sort signals, stable sort, and client vs server.

intermediate4 min read
  • machine-coding
  • interview
  • react
  • tables
  • a11y

Problem statement

Build a sortable data table: columns with headers that toggle ascending/descending sort; body renders rows from data. Interviewers score sort state design, comparator correctness, and table accessibility (aria-sort) — not Excel.

Requirements

Must have

  • Render tabular data from typed rows + column config
  • Click column header to sort
  • Toggle asc ↔ desc; optional third click clears sort
  • Indicate active sort column + direction visually and via ARIA
  • Stable empty/null handling in comparators

Should have

  • Controlled sort prop for server-side sort mode
  • Keyboard activation on headers (buttons)
  • Sticky header CSS note

Nice to have

  • Multi-column sort
  • Column resize
  • Integrate pagination / filters

Planning (5 minutes out loud)

  1. Column def — key, header, sortable, sortFn or value accessor
  2. Client sort copies array — never mutate props
  3. aria-sort on th
  4. MVP — one active column sort; then controlled mode
  5. Strings vs numbers — type-aware compare

Architecture

DataTable
├── thead > SortableHeaderCell
└── tbody > Row

Types

type SortDir = "asc" | "desc";

type SortState<K extends string = string> = {
  key: K;
  direction: SortDir;
} | null;

type Column<T> = {
  key: keyof T & string;
  header: string;
  sortable?: boolean;
  accessor?: (row: T) => string | number | null | undefined;
  render?: (row: T) => React.ReactNode;
  align?: "left" | "right";
};

type DataTableProps<T> = {
  rows: T[];
  columns: Column<T>[];
  sort?: SortState;
  defaultSort?: SortState;
  onSortChange?: (sort: SortState) => void;
  getRowId: (row: T) => string;
};

Implementation sketch

function defaultAccess<T>(row: T, key: keyof T) {
  return row[key] as unknown as string | number | null | undefined;
}

function compareValues(
  a: string | number | null | undefined,
  b: string | number | null | undefined,
  dir: SortDir
) {
  const mul = dir === "asc" ? 1 : -1;
  if (a == null && b == null) return 0;
  if (a == null) return 1 * mul; // nulls last in asc — pick a rule
  if (b == null) return -1 * mul;
  if (typeof a === "number" && typeof b === "number") return (a - b) * mul;
  return String(a).localeCompare(String(b), undefined, { numeric: true }) * mul;
}

function sortRows<T>(
  rows: T[],
  columns: Column<T>[],
  sort: SortState
): T[] {
  if (!sort) return rows;
  const col = columns.find((c) => c.key === sort.key);
  if (!col) return rows;
  const get = col.accessor ?? ((r: T) => defaultAccess(r, col.key));
  return rows
    .map((row, index) => ({ row, index }))
    .sort((x, y) => {
      const c = compareValues(get(x.row), get(y.row), sort.direction);
      return c !== 0 ? c : x.index - y.index; // stable
    })
    .map((x) => x.row);
}

Header + table

function DataTable<T>({
  rows,
  columns,
  sort: controlled,
  defaultSort = null,
  onSortChange,
  getRowId,
}: DataTableProps<T>) {
  const [uncontrolled, setUncontrolled] = useState<SortState>(defaultSort);
  const sort = controlled !== undefined ? controlled : uncontrolled;

  function setSort(next: SortState) {
    if (controlled === undefined) setUncontrolled(next);
    onSortChange?.(next);
  }

  function toggle(key: string) {
    if (!sort || sort.key !== key) {
      setSort({ key, direction: "asc" });
      return;
    }
    if (sort.direction === "asc") setSort({ key, direction: "desc" });
    else setSort(null); // third click clear — optional
  }

  const sorted =
    controlled !== undefined && onSortChange
      ? rows // server already sorted
      : useMemo(() => sortRows(rows, columns, sort), [rows, columns, sort]);

  return (
    <table>
      <thead>
        <tr>
          {columns.map((col) => {
            const active = sort?.key === col.key;
            const ariaSort = !col.sortable
              ? undefined
              : !active
                ? "none"
                : sort!.direction === "asc"
                  ? "ascending"
                  : "descending";
            return (
              <th key={col.key} aria-sort={ariaSort}>
                {col.sortable ? (
                  <button type="button" onClick={() => toggle(col.key)}>
                    {col.header}
                    {active ? (sort!.direction === "asc" ? " ↑" : " ↓") : ""}
                  </button>
                ) : (
                  col.header
                )}
              </th>
            );
          })}
        </tr>
      </thead>
      <tbody>
        {sorted.map((row) => (
          <tr key={getRowId(row)}>
            {columns.map((col) => (
              <td key={col.key}>
                {col.render
                  ? col.render(row)
                  : String(defaultAccess(row, col.key) ?? "")}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Note: hooks can’t be conditional — in real code always useMemo and choose data source inside.

Accessibility essentials

  • Real <table>, <th scope="col">
  • Sortable headers are buttons (not clickable th alone without button)
  • aria-sort on column header cell
  • Caption or aria-label on table
  • Don’t use color alone for direction — include text/icon with accessible name

Performance notes

  • Client sort O(n log n) fine for thousands
  • Server sort when dataset is large — controlled mode
  • Memo sorted array; virtualize body when huge (Virtualized Data Table)

Footguns

  1. Mutating rows.sort in place
  2. Unstable sort shuffling equal keys every click
  3. Mixing string/number compare incorrectly ("10" < "2")
  4. Calling hooks conditionally for client vs server
  5. Missing scope / div soup instead of table semantics

Interview out-loud answer

Columns are config objects; sort state is {key, direction}. Client mode copies and sorts with a typed comparator and stable index tiebreak; server mode is controlled and only updates sort state. Headers are buttons with aria-sort. I’d clear or toggle sort predictably and keep pagination as a parent concern.

Further reading