Sortable Data Table
Machine-coding brief for a sortable data table — column sort state, a11y sort signals, stable sort, and client vs server.
- 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
sortprop 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)
- Column def —
key,header,sortable,sortFnor value accessor - Client sort copies array — never mutate props
- aria-sort on
th - MVP — one active column sort; then controlled mode
- 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
thalone without button) aria-sorton column header cell- Caption or
aria-labelon 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
- Mutating
rows.sortin place - Unstable sort shuffling equal keys every click
- Mixing string/number compare incorrectly (
"10" < "2") - Calling hooks conditionally for client vs server
- 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.
Related on this site
- Virtualized Data Table
- Pagination Component
- Product Filters Sidebar
- Machine Coding Interview Framework