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.
01DataTable is domain-agnostic and controlled by query state.
02Toolbar owns labelled search and bulk actions.
03SortableHeader keeps th semantics with an inner button.
04Selection uses current visible row ids.
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.
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.
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
1import{ useMemo, useState }from"react";23type Row=Record<string, unknown>&{id: string };4type Sort={key: string; direction:"asc"|"desc"}|null;5type Column={key: string; header: string; sortable?: boolean };67exportfunctionDataTable({ rows, columns, pageSize =10}:{8rows:Row[]; columns:Column[]; pageSize?: number;9}){10const[filter, setFilter]=useState("");11const[sort, setSort]= useState<Sort>(null);12const[page, setPage]=useState(1);13const[selected, setSelected]= useState<Set<string>>(newSet());1415// The entire table is one pure pipeline: filter, then sort, then slice.16const{ visible, pageCount, total }=useMemo(()=>{17const needle = filter.trim().toLowerCase();18const filtered = needle19? rows.filter((row)=>20 columns.some((col)=>21String(row[col.key]??"").toLowerCase().includes(needle),22),23)24: rows;2526const sorted = sort27? filtered28.map((row, i)=>[row, i]asconst)// remember original order29.sort(([a, ai],[b, bi])=>{30const av = a[sort.key];31const bv = b[sort.key];32let cmp =33 av ==null?134: bv ==null?-135:typeof av ==="number"&&typeof bv ==="number"? av - bv36:String(av).localeCompare(String(bv));37if(sort.direction==="desc") cmp =-cmp;38return cmp !==0? cmp : ai - bi;// stable tie-break39})40.map(([row])=> row)41: filtered;4243const pageCount =Math.max(1,Math.ceil(sorted.length/ pageSize));44const safePage =Math.min(page, pageCount);45const start =(safePage -1)* pageSize;46return{47visible: sorted.slice(start, start + pageSize),48 pageCount,49total: sorted.length,50};51},[rows, columns, filter, sort, page, pageSize]);5253functioncycleSort(key: string){54setPage(1);55setSort((prev)=>56 prev?.key !== key ?{ key,direction:"asc"}57: prev.direction==="asc"?{ key,direction:"desc"}58:null,59);60}6162const visibleIds = visible.map((r)=> r.id);63const allOnPage = visibleIds.length>0&& visibleIds.every((id)=> selected.has(id));64const someOnPage = visibleIds.some((id)=> selected.has(id))&&!allOnPage;6566functiontoggleAllOnPage(){67setSelected((prev)=>{68const next =newSet(prev);69 visibleIds.forEach((id)=>(allOnPage ? next.delete(id): next.add(id)));70return next;71});72}7374functiontoggleRow(id: string){75setSelected((prev)=>{76const next =newSet(prev);77if(next.has(id)) next.delete(id);78else next.add(id);79return next;80});81}8283return(84<div>85<label>86Search87<input value={filter} onChange={(e)=>{setFilter(e.target.value);setPage(1);}}/>88</label>8990<table>91<thead>92<tr>93<th scope="col">94<input95 type="checkbox"96 aria-label="Select all rows on this page"97 checked={allOnPage}98 ref={(el)=>{if(el) el.indeterminate= someOnPage;}}99 onChange={toggleAllOnPage}100/>101</th>102{columns.map((col)=>{103const active = sort?.key === col.key;104const ariaSort = active105? sort.direction==="asc"?"ascending":"descending"106:"none";107return(108<th key={col.key} scope="col" aria-sort={ariaSort}>109{col.sortable===false? col.header:(110<button type="button" onClick={()=>cycleSort(col.key)}>111{col.header}{active ?(sort.direction==="asc"?" \u25b2":" \u25bc"):""}112</button>113)}114</th>115);116})}117</tr>118</thead>119<tbody>120{visible.map((row)=>(121<tr key={row.id} aria-selected={selected.has(row.id)}>122<td>123<input124 type="checkbox"125 aria-label={"Select row "+ row.id}126 checked={selected.has(row.id)}127 onChange={()=>toggleRow(row.id)}128/>129</td>130{columns.map((col)=><td key={col.key}>{String(row[col.key]??"")}</td>)}131</tr>132))}133</tbody>134</table>135136<p aria-live="polite">137{total ===0138?"No results"139:"Showing "+ visible.length+" of "+ total +" \u00b7 "+ selected.size+" selected"}140</p>141<button type="button" disabled={page <=1} onClick={()=>setPage((p)=> p -1)}>Previous</button>142<span>Page{Math.min(page, pageCount)}of{pageCount}</span>143<button type="button" disabled={page >= pageCount} onClick={()=>setPage((p)=> p +1)}>Next</button>144</div>145);146}
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).