Skip to content
Intermediate60 min build target6 min guide

Data table

A sortable, filterable, paginated table over a dataset, with column-level control.

Data table interface reference

HOW TO USE THIS CHALLENGE

  1. 1. Read the briefClarify decisions before coding.
  2. 2. Build from memoryUse the 60-minute target.
  3. 3. Study the guideCompare architecture, tests, and trade-offs.

REQUIREMENTS

  • Sort by clicking a column header, toggling ascending/descending/none.
  • Filter rows via a text search across one or more columns.
  • Paginate results with a configurable page size.
  • Support selecting rows (single and 'select all on page') for bulk actions.
  • Show clear empty and loading states.

EDGE CASES

  • Changing the filter should reset to page 1, or pagination can point at a page that no longer exists.
  • Sorting must handle mixed types (numbers as strings, nulls) without throwing or silently misordering.
  • 'Select all' should reflect an indeterminate state when only some rows on the page are selected.
  • Selection state across pages: decide and implement whether selecting page 2 preserves page 1's selection (it should, by row id, not by row index).

ACCESSIBILITY

  • Table uses semantic <table>/<th scope="col">; sortable headers expose aria-sort.
  • Sort and pagination controls are real buttons, reachable and operable by keyboard.
  • Row selection checkboxes have programmatic labels tied to the row's identifying content.

SUGGESTED APPROACH

  • Keep raw data immutable; derive the filtered → sorted → paginated result with a memoized pipeline so each step is cheap to reason about and test independently.
  • Track selection by a Set of row ids, never row index, so it survives sorting and filtering.
  • Store sort state as { column, direction } and pagination as { page, pageSize }, resetting page to 1 whenever the filter or sort changes the underlying result set meaningfully.

EVALUATION RUBRIC

  • Sort, filter, and pagination compose correctly together (e.g. filtering then sorting then paging matches the correct final rows).
  • Selection state is keyed by row id and survives sorting/filtering/paging.
  • Sortable headers expose aria-sort and are keyboard-operable.
  • Handles empty results and an out-of-range page without crashing.

01Understand the product before coding

Learning goals

  • Sort by clicking a column header, toggling ascending/descending/none.
  • Filter rows via a text search across one or more columns.
  • Paginate results with a configurable page size.
  • Support selecting rows (single and 'select all on page') for bulk actions.

Decisions to state aloud

  • Changing the filter should reset to page 1, or pagination can point at a page that no longer exists.
  • Sorting must handle mixed types (numbers as strings, nulls) without throwing or silently misordering.
  • 'Select all' should reflect an indeterminate state when only some rows on the page are selected.
  • Selection state across pages: decide and implement whether selecting page 2 preserves page 1's selection (it should, by row id, not by row index).

02State model and invariants

Raw rows remain immutable. Query state holds filter, sort, page, and size. Selection is a Set of stable ids. Derive filtered, stably sorted, then paginated rows in exactly that order.

TypeScript
type Sort<Row> = { key: keyof Row; direction: 'asc' | 'desc' } | null;type TableState<Row> = {  filter: string; sort: Sort<Row>; page: number; pageSize: number;  selectedIds: Set<string>;};type Column<Row, K extends keyof Row = keyof Row> = {  key: K; header: string; sortable?: boolean;  compare?: (a: Row[K], b: Row[K]) => number;  render: (value: Row[K], row: Row) => React.ReactNode;};

03Component architecture

  1. 01DataTable is domain-agnostic and controlled by query state.
  2. 02Toolbar owns labelled search and bulk actions.
  3. 03SortableHeader keeps th semantics with an inner button.
  4. 04Selection uses current visible row ids.
  5. 05Pagination reports ranges and disables impossible actions.

04Reference implementation walkthrough

Step 1

Compose a pure pipeline

Normalize the filter, filter allowed fields, apply stable comparison, clamp the page, then slice. Paging before filtering or sorting produces incorrect global results.

TypeScript
const filtered = filterRows(rows, state.filter);const sorted = state.sort ? stableSort(filtered, state.sort) : filtered;const pageCount = Math.max(1, Math.ceil(sorted.length / state.pageSize));const page = Math.min(state.page, pageCount);const start = (page - 1) * state.pageSize;const visible = sorted.slice(start, start + state.pageSize);

Step 2

Expose sort semantics

Use a button inside th and set aria-sort on only the active sorted header. Cycle none, ascending, and descending according to the declared product behavior.

TSX
<th scope="col" aria-sort={direction ?? 'none'}>  <button type="button" onClick={() => cycleSort(column.key)}>    {column.header}<SortIcon direction={direction} />  </button></th>

Step 3

Define selection scope

Select-page changes exactly visible ids and sets the header checkbox's indeterminate property when some are selected. A separate explicit contract is required for all server-matching results.

Step 4

Separate server authority

For remote data, query state becomes the request key, stale requests are cancelled, and the server supplies total count and global ordering. Never resort one server page and present it as globally sorted.

SOLComplete solution, explained simply

Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.

We are building a table you can search, sort by column, page through, and select rows in. The one big idea: the raw rows never change. Everything on screen is derived by running one pure pipeline in a fixed order — filter the whole set, then sort that, then cut out one page. Selection is a Set of row ids so it survives every one of those transforms.

DataTable.tsx
import { useMemo, useState } from "react";
type Row = Record<string, unknown> & { id: string };type Sort = { key: string; direction: "asc" | "desc" } | null;type Column = { key: string; header: string; sortable?: boolean };
export function DataTable({ rows, columns, pageSize = 10 }: {  rows: Row[]; columns: Column[]; pageSize?: number;}) {  const [filter, setFilter] = useState("");  const [sort, setSort] = useState<Sort>(null);  const [page, setPage] = useState(1);  const [selected, setSelected] = useState<Set<string>>(new Set());
  // The entire table is one pure pipeline: filter, then sort, then slice.  const { visible, pageCount, total } = useMemo(() => {    const needle = filter.trim().toLowerCase();    const filtered = needle      ? rows.filter((row) =>          columns.some((col) =>            String(row[col.key] ?? "").toLowerCase().includes(needle),          ),        )      : rows;
    const sorted = sort      ? filtered          .map((row, i) => [row, i] as const)   // remember original order          .sort(([a, ai], [b, bi]) => {            const av = a[sort.key];            const bv = b[sort.key];            let cmp =              av == null ? 1              : bv == null ? -1              : typeof av === "number" && typeof bv === "number" ? av - bv              : String(av).localeCompare(String(bv));            if (sort.direction === "desc") cmp = -cmp;            return cmp !== 0 ? cmp : ai - bi;   // stable tie-break          })          .map(([row]) => row)      : filtered;
    const pageCount = Math.max(1, Math.ceil(sorted.length / pageSize));    const safePage = Math.min(page, pageCount);    const start = (safePage - 1) * pageSize;    return {      visible: sorted.slice(start, start + pageSize),      pageCount,      total: sorted.length,    };  }, [rows, columns, filter, sort, page, pageSize]);
  function cycleSort(key: string) {    setPage(1);    setSort((prev) =>      prev?.key !== key ? { key, direction: "asc" }      : prev.direction === "asc" ? { key, direction: "desc" }      : null,    );  }
  const visibleIds = visible.map((r) => r.id);  const allOnPage = visibleIds.length > 0 && visibleIds.every((id) => selected.has(id));  const someOnPage = visibleIds.some((id) => selected.has(id)) && !allOnPage;
  function toggleAllOnPage() {    setSelected((prev) => {      const next = new Set(prev);      visibleIds.forEach((id) => (allOnPage ? next.delete(id) : next.add(id)));      return next;    });  }
  function toggleRow(id: string) {    setSelected((prev) => {      const next = new Set(prev);      if (next.has(id)) next.delete(id);      else next.add(id);      return next;    });  }
  return (    <div>      <label>        Search        <input value={filter} onChange={(e) => { setFilter(e.target.value); setPage(1); }} />      </label>
      <table>        <thead>          <tr>            <th scope="col">              <input                type="checkbox"                aria-label="Select all rows on this page"                checked={allOnPage}                ref={(el) => { if (el) el.indeterminate = someOnPage; }}                onChange={toggleAllOnPage}              />            </th>            {columns.map((col) => {              const active = sort?.key === col.key;              const ariaSort = active                ? sort.direction === "asc" ? "ascending" : "descending"                : "none";              return (                <th key={col.key} scope="col" aria-sort={ariaSort}>                  {col.sortable === false ? col.header : (                    <button type="button" onClick={() => cycleSort(col.key)}>                      {col.header}{active ? (sort.direction === "asc" ? " \u25b2" : " \u25bc") : ""}                    </button>                  )}                </th>              );            })}          </tr>        </thead>        <tbody>          {visible.map((row) => (            <tr key={row.id} aria-selected={selected.has(row.id)}>              <td>                <input                  type="checkbox"                  aria-label={"Select row " + row.id}                  checked={selected.has(row.id)}                  onChange={() => toggleRow(row.id)}                />              </td>              {columns.map((col) => <td key={col.key}>{String(row[col.key] ?? "")}</td>)}            </tr>          ))}        </tbody>      </table>
      <p aria-live="polite">        {total === 0          ? "No results"          : "Showing " + visible.length + " of " + total + " \u00b7 " + selected.size + " selected"}      </p>      <button type="button" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>Previous</button>      <span> Page {Math.min(page, pageCount)} of {pageCount} </span>      <button type="button" disabled={page >= pageCount} onClick={() => setPage((p) => p + 1)}>Next</button>    </div>  );}

How each part works

Raw rows are never mutated

The rows prop is treated as read-only. All the state — filter text, sort, page, selected ids — is small and separate. What you see is computed fresh from rows plus that state, so there is no risk of the display and the source data drifting apart.

The pipeline runs in a fixed order

Filter the full set first, then sort the filtered result, then slice one page out of the sorted result. Order matters: if you paged first, the total count and the sort would only reflect one page, which is wrong. Doing it as one useMemo means it only recomputes when an input actually changes.

Sorting is made stable on purpose

Before sorting we pair each row with its original index. When two rows compare equal, we fall back to that index, so equal rows keep their prior relative order instead of jumping around. Nulls are pushed to the end regardless of direction. Numbers compare numerically; everything else uses localeCompare.

Selection is a Set of ids

Row positions change under filter, sort, and paging, so selecting 'row 3' would be meaningless. Selecting by stable id means a checked row stays checked when it moves to a different page or sort position. selected.size is the running total across all pages.

The header checkbox has three states

checked when every visible row is selected, unchecked when none are, and indeterminate (a dash) when some are. React has no indeterminate prop, so we set it through a ref callback on the DOM element. 'Select all' here means all rows on the current page — selecting every matching row across pages needs its own explicit control.

Sortable headers stay real table headers

Each th keeps scope="col" and puts a real button inside for the click target. aria-sort is set to ascending or descending only on the one active column and none everywhere else, so assistive technology announces the current sort.

Pagination clamps instead of trusting state

If filtering shrinks the results below the current page, safePage = Math.min(page, pageCount) keeps the view valid without needing an effect to reset page. The Previous and Next buttons are disabled at the boundaries.

Why this is correct

  • Immutable source rows plus small separate query state means the rendered table is always a pure function of its inputs.
  • Filter, then sort, then paginate — in that order — or totals and global ordering come out wrong.
  • A stable sort with an original-index tie-break stops equal rows from visually shuffling on every re-sort.
  • Selecting rows by id, not index, is what lets a selection survive sorting, filtering, and page changes.
  • The 'select all' checkbox needs an explicit indeterminate state and a clear scope: this page versus every matching row.

05Testing strategy

Critical behavior

  • Sort, filter, and pagination compose correctly together (e.g. filtering then sorting then paging matches the correct final rows).
  • Selection state is keyed by row id and survives sorting/filtering/paging.
  • Sortable headers expose aria-sort and are keyboard-operable.
  • Handles empty results and an out-of-range page without crashing.

Failure and boundary cases

  • Changing the filter should reset to page 1, or pagination can point at a page that no longer exists.
  • Sorting must handle mixed types (numbers as strings, nulls) without throwing or silently misordering.
  • 'Select all' should reflect an indeterminate state when only some rows on the page are selected.
  • Selection state across pages: decide and implement whether selecting page 2 preserves page 1's selection (it should, by row id, not by row index).

Accessibility

  • Table uses semantic <table>/<th scope="col">; sortable headers expose aria-sort.
  • Sort and pagination controls are real buttons, reachable and operable by keyboard.
  • Row selection checkboxes have programmatic labels tied to the row's identifying content.

06Performance and production hardening

  • Move operations server-side when full transfer is the bottleneck.
  • Memoize by immutable rows and query state.
  • Create Intl.Collator once per policy.
  • Virtualize only with preserved table or grid semantics.

QAInterview questions and model answers

Answer aloud first. Then open the model answer and compare state ownership, failure handling, accessibility, and trade-offs—not exact wording.

Model answer

Filter the full set, sort that result, then paginate. A different order makes totals and global ordering incorrect.

Primary references

Ready to build it?

Implement the brief above in your own environment against a timer close to 60 minutes, then self-review against the rubric before moving on.

Back to all briefs →