UI build: sortable, filterable, paginated data table
The rows on screen are derived on every render from the given rows and three pieces of view state; the given rows never move.
Predict first
Before you read: predict
ExamHall's proctor console lists ECON-201's 2,400 attempts. attempts holds them in submission order, and no new attempt arrives while the proctor works.
The proctor types lee into the search box, then presses Sort by score, then clears the search box.
After which step does the Latest submissions list first change?
function ProctorResults({ attempts }) { const [query, setQuery] = useState(''); const [sortKey, setSortKey] = useState(null); return ( <> <input aria-label="Search" value={query} onChange={(e) => setQuery(e.target.value)} /> <button onClick={() => setSortKey('score')}>Sort by score</button> <ResultsTable attempts={attempts} query={query} sortKey={sortKey} /> <LatestSubmissions attempts={attempts} /> </> );} function ResultsTable({ attempts, query, sortKey }) { const shown = query ? attempts.filter((a) => a.name.toLowerCase().includes(query)) : attempts; if (sortKey) shown.sort((a, b) => b[sortKey] - a[sortKey]); return ( <table> <tbody> {shown.slice(0, 50).map((a) => ( <tr key={a.id}><td>{a.name}</td><td>{a.score}</td></tr> ))} </tbody> </table> );} function LatestSubmissions({ attempts }) { return <ol>{attempts.slice(-3).map((a) => <li key={a.id}>{a.name}</li>)}</ol>;}The 30-second version
A table's view state is the search text, the sort column and direction, and the page. Each render derives the visible rows from the rows it was given: filter, sort, then slice one page, clamping the page to the last one. sort() reorders the array it is called on; filter() and slice() return new arrays. aria-sort marks the sorted header.
The rows on screen are derived on every render from the given rows and three pieces of view state; the given rows never move.
How it works
Step 1 of 11
In the kitchen
- the register and the racks, boxes in arrival order → the given rows, in source order (step 1)
- a geologist's request: rock, order, trolley → the view state: search, sort, page (step 1)
Analogy: A geological survey's core store (earth science)
A survey's core store keeps drill-core boxes on numbered racks, in the order they arrived; its register lists them in that order, and a technician fetches a box by its rack position. Geologists write in: "sandstone boxes, deepest first, eight to a trolley, trolley 2". The clerk writes each trolley list fresh from the register.
| At the core store | In the table |
|---|---|
| the register and the racks, boxes in arrival order | the given rows, in source order (step 1) |
| a geologist's request: rock, order, trolley | the view state: search, sort, page (step 1) |
| a trolley list written fresh from the register for each request | the rows derived on every render (step 2) |
| re-racking the boxes by depth to write lists faster: the technician, who fetches by rack position, brings the wrong box | sort() on the given rows (steps 3–4) |
| two boxes from one depth go on the list in register order | a stable sort of the given rows (steps 6–7) |
| "trolley 7" for a request that now fills one trolley: the clerk sends trolley 1, the last there is | the clamped page (steps 8–9) |
| an arrow beside the depth heading of the list, and beside no other | aria-sort on one header (step 10) |
In real terms: the table keeps search, sort and page; every render filters the given rows, sorts a copy, and slices the clamped page.
Breaks down when: a list costs the clerk an hour, so she keeps old lists for repeat requests; a derive of 2,400 rows is cheap and keeps nothing between renders.
Contrasting case: a core library shelved by depth from the start. The order is part of the store itself, as when the server sends rows already sorted and paged.
Examples, run for real
4.1 ExamHall EXH-2880: sorting the given rows
The proctor results table shows ECON-201's 2,400 attempts, with sort by score and a name filter. Its first draft sorted the attempts prop itself. The console's CSV export reads the same array and must list attempts in submission order (EXH-2880). MR !3180 sorts a copy with toSorted, and adds the test list.
4.1 draft (sorts the matches it gets, the attempts themselves when the search is empty): 2,400 attempts; CSV export's first three before a sort a-0001, a-0002, a-0003, after one click on Score a-0043, a-0055, a-0070
4.1 MR !3180 (toSorted): 2,400 attempts; CSV export's first three before a sort a-0001, a-0002, a-0003, after one click on Score a-0001, a-0002, a-0003
4.1 test list (table.test.mjs) on the draft: 9 of 10 pass · red: T6 "sorting and searching leave the rows array as it was"
4.1 test list on MR !3180's build (reference-table.jsx): 10 of 10 pass
The draft's sort reordered the console's own list, so the export changed with the table (step 3). The copy leaves it alone (step 4).
4.2 Ticketing: one derive, three view states
A box office's orders table derives its page from 23 orders, ten to a page:
function visibleRows(rows, { query, sort, page }, size) {
const q = query.toLowerCase();
const matches = q ? rows.filter((r) => r.buyer.toLowerCase().includes(q)) : rows;
const sorted = sort
? matches.toSorted((x, y) => (sort.dir === 'asc' ? 1 : -1) * (x[sort.key] - y[sort.key]))
: matches;
const pageCount = Math.max(1, Math.ceil(sorted.length / size));
const current = Math.min(page, pageCount);
return { rows: sorted.slice((current - 1) * size, current * size), current, pageCount };
}
4.2 search "", total highest first, page state 3 -> page 3 of 3 · 3 rows: Maya Roth 45, Ben Ito 44, Ada Moss 38
4.2 search "ng", total highest first, page state 3 -> page 1 of 1 · 4 rows: Inga Holt 152, Wang Li 116, Dan Long 81, Mei Ng 46
4.2 search "", total highest first, page state 3 -> page 3 of 3 · 3 rows: Maya Roth 45, Ben Ito 44, Ada Moss 38
4.2 the 23 orders after the three views: same objects, same order (o-01 … o-23)
The search narrows the list to one page, so page 3 shows as page 1 of 1 (step 9). The stored page stays 3, so clearing the search returns to it.
4.3 The headers after three clicks
The reference build on ExamHall's results, after Score, Score again, then Name:
4.3 after Score: Name - · Score ascending · axe no ARIA attribute errors
4.3 after Score: Name - · Score descending · axe no ARIA attribute errors
4.3 after Name: Name ascending · Score - · axe no ARIA attribute errors
One header at a time carries aria-sort, and its value follows the clicks (step 10).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 8
A box office lists its events four to a page:
A fan picks Leeds and stays on page 1, which must list the four cheapest Leeds events, cheapest first.
Which expression, in place of BODY, does that?
const events = [ // in date order { act: 'Kora Lane', city: 'York', price: 30 }, { act: 'The Fens', city: 'Leeds', price: 40 }, { act: 'Milo Ray', city: 'Leeds', price: 25 }, { act: 'Dune Choir', city: 'Hull', price: 20 }, { act: 'Petra Vale', city: 'York', price: 35 }, { act: 'Ostin', city: 'Hull', price: 15 }, { act: 'Sable', city: 'Leeds', price: 15 }, { act: 'Nine Gates', city: 'York', price: 50 }, { act: 'Juno Park', city: 'Leeds', price: 30 }, { act: 'Halden', city: 'Leeds', price: 20 },];const byPrice = (a, b) => a.price - b.price;const inCity = (city) => (e) => e.city === city; function visibleEvents(events, { city, page }) { const start = (page - 1) * 4; return BODY;}Question 2 of 8
MR !3181 adds a search box and paging to the proctor results table. The table's new code:
Four review comments came in.
Which one names a real defect?
function ResultsTable({ attempts }) { const [query, setQuery] = useState(''); const [page, setPage] = useState(1); const matches = attempts.filter((a) => a.name.toLowerCase().includes(query.toLowerCase())); const ranked = matches.toSorted((a, b) => b.score - a.score); const pageCount = Math.max(1, Math.ceil(ranked.length / 50)); const rows = ranked.slice((page - 1) * 50, page * 50); return ( <> <input aria-label="Search" value={query} onChange={(e) => setQuery(e.target.value)} /> <p>{ranked.length} of {attempts.length} attempts · page {page} of {pageCount}</p> <table> <tbody>{rows.map((a) => <tr key={a.id}><td>{a.name}</td><td>{a.score}</td></tr>)}</tbody> </table> <button disabled={page === 1} onClick={() => setPage(page - 1)}>Previous</button> <button disabled={page === pageCount} onClick={() => setPage(page + 1)}>Next</button> </> );}Question 3 of 8
A video app's Most watched table:
The viewer clicks Title, then Views.
In what order does the table list the three videos with 1,200 views?
const videos = [ // in upload order { id: 'v1', title: 'Ocean Drift', views: 1200 }, { id: 'v2', title: 'Kite Hill', views: 2900 }, { id: 'v3', title: 'Amber Road', views: 1200 }, { id: 'v4', title: 'Lumen', views: 640 }, { id: 'v5', title: 'Fjord', views: 1200 },]; const compare = { title: (a, b) => a.title.localeCompare(b.title), views: (a, b) => b.views - a.views,}; function MostWatched({ videos }) { const [sortBy, setSortBy] = useState(null); const shown = sortBy ? videos.toSorted(compare[sortBy]) : videos; return ( <table> <thead> <tr> <th><button onClick={() => setSortBy('title')}>Title</button></th> <th><button onClick={() => setSortBy('views')}>Views</button></th> </tr> </thead> <tbody>{shown.map((v) => <tr key={v.id}><td>{v.title}</td><td>{v.views}</td></tr>)}</tbody> </table> );}Question 4 of 8
A payments ledger's transactions table renders each column's header cell, with a sort button inside it, through Header; CSS draws an arrow on the sorted column from a class:
sort starts as { key: 'date', dir: 'desc' }; a click on another column sorts it 'asc', and a second click on the same column 'desc'. A screen-reader user moving across the Date, Payee and Amount headers hears their names and nothing about the sort.
Which change to Header lets the headers tell her which column is sorted and which way, after any clicks?
function Header({ col, sort, onSort }) { return ( <th className={sort.key === col.key ? `sorted ${sort.dir}` : ''}> <button onClick={() => onSort(col.key)}>{col.label}</button> </th> );}Question 5 of 8
the interviewer says: "Build ResultsTable({ rows, columns, pageSize }). rows are objects with an id and one field per column, in source order; columns are { key, label }. Give it a search box named Search that keeps rows whose text in any column contains the search, ignoring case; a header button per column, the first click sorting ascending and a click on the sorted column flipping it; numbers compared as numbers and text with localeCompare; pageSize rows a page with Previous and Next buttons and a line 'Page N of M'; and 'No matches' when nothing matches." You have 30 minutes, and ExamHall's proctor results table is the use case.
Before you type: say your test list out loud.
Name at least six tests, each with what it does and what it expects.
Question 6 of 8
the interviewer says: "Build ResultsTable({ rows, columns, pageSize }). rows are objects with an id and one field per column, in source order; columns are { key, label }. Give it a search box named Search that keeps rows whose text in any column contains the search, ignoring case; a header button per column, the first click sorting ascending and a click on the sorted column flipping it; numbers compared as numbers and text with localeCompare; pageSize rows a page with Previous and Next buttons and a line 'Page N of M'; and 'No matches' when nothing matches." You have 30 minutes, and ExamHall's proctor results table is the use case.
Now build it, in 30 minutes, from a blank file that exports ResultsTable, with useState in scope.
Question 7 of 8
A chat app's room settings page lists the room's members. members holds them in the order they joined:
The page must list owners, then admins, then members, and the people inside each role by name.
Which expression gives that list?
const members = [ { name: 'Zara', role: 'member' }, { name: 'Bo', role: 'admin' }, { name: 'Kim', role: 'member' }, { name: 'Ali', role: 'owner' }, { name: 'Max', role: 'admin' }, { name: 'Eve', role: 'member' },];const rank = { owner: 0, admin: 1, member: 2 };const byRole = (a, b) => rank[a.role] - rank[b.role];const byName = (a, b) => a.name.localeCompare(b.name);Question 8 of 8
A shop lists its jackets by price, in either direction:
The page's New in strip, newIn, reads jackets too and shows its first three. Lowest price first lists the three €45 jackets in listing order; highest price first lists them the other way round. The team wants equal prices in listing order in both directions, and the strip showing the three jackets listed first whatever the sort.
Which line, in place of const shown = …, does both?
const jackets = [ // in listing order { name: 'Harbour Shell', price: 45 }, { name: 'Fell Down', price: 120 }, { name: 'Rain Cape', price: 45 }, { name: 'Trail Fleece', price: 60 }, { name: 'Bothy Parka', price: 45 }, { name: 'Ridge Gilet', price: 30 },]; function visibleJackets(jackets, { query, dir }) { const matches = query ? jackets.filter((j) => j.name.toLowerCase().includes(query)) : jackets; const asc = matches.toSorted((a, b) => a.price - b.price); const shown = dir === 'desc' ? asc.reverse() : asc; return shown;} function newIn(jackets) { return jackets.slice(0, 3);}Go deeper
Pro tips
- Say "state is search, sort and page; the rows on screen are derived" before you type. When: the first minute, while you set out the component (steps 1–2). Verify: react.dev says "If you can calculate some information from the component's props or its existing state variables during rendering, you should not put that information into that component's state." (The curriculum row's tip, [SELF].)
- Run your build against the list before you call it done:
TABLE_IMPL=./my-table.jsx node --test table.test.mjs. When: practising the build at home, against the tests the reference passes (step 11). Verify (RAN,tips.mjs):tip 2 TABLE_IMPL=./drafts/m0-empty.jsx (renders nothing): exit 1 · tests 10 · pass 0 · fail 10
Source
Docs and guides change, so their claims are labelled as of 2026-10-04; every behaviour this lesson grades is executed.
- W3C WAI-ARIA Authoring Practices, Sortable Table Example (the curriculum row's third source), https://www.w3.org/WAI/ARIA/apg/patterns/table/examples/sortable-table/ (
apg-sortable-table.txt). "The aria-sort attribute is set on the column header of the currently sorted column, and the header text of sortable columns is wrapped in a button element"; "When the sorted column is changed, the aria-sort attribute is removed and set on the newly sorted column" (step 10). As of 2026-10-04. - react.dev, Choosing the State Structure (the row's second source), https://react.dev/learn/choosing-the-state-structure (
react-choosing-state.txt). "If you can calculate some information from the component's props or its existing state variables during rendering, you should not put that information into that component's state" (steps 1–2, tip 1). As of 2026-10-04. - Front End Interview Handbook, Build front end user interfaces (the row's first source), https://www.frontendinterviewhandbook.com/coding/build-front-end-user-interfaces (
fih-build-ui.txt). It lists "Data Table (with extensions for sorting and filtering)" among the questions, and asks "What if there are too many items? Hint: set max height or paginate" and "What if there are no items, what do you show? Hint: show an empty state to indicate absence of contents". As of 2026-10-04. - MDN, Array.prototype.sort() and Array.prototype.toSorted(), https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort and …/Array/toSorted (
mdn-array-sort.txt,mdn-array-tosorted.txt).sort"sorts the elements of an array in place and returns the reference to the same array, now sorted"; "Since version 10 (or ECMAScript 2019), the specification dictates that Array.prototype.sort is stable"; the comparator's "Zero or NaN indicates that a and b are considered equal" (steps 3, 5, 6). As of 2026-10-04. - W3C, WAI-ARIA 1.2, https://www.w3.org/TR/wai-aria-1.2/ (
wai-aria-1-2.txt).aria-sort: "Authors SHOULD only apply this property to table headers or grid headers. If the property is not provided, there is no defined sort order. For each table or grid, authors SHOULD apply aria-sort to only one header at a time"; "User agents MUST ignore non-global states and properties used on an element without a role supporting the state or property". As of 2026-10-04.
Related lessons
Checked against the tools and docs as of 2026-10-04.