Design a data table
A reusable data table for a design system that sorts, filters, pages, selects, resizes, and edits rows. It looks like an HTML table with a few buttons, but the hard parts are where the data lives (browser or server), keeping 100,000 rows smooth, a column API that 30 teams can share, and a keyboard and screen reader story that still works when most rows aren't in the DOM.
The question
Design a data table component for our design system. The first users are the Orders page of our back office (about 250,000 orders), a small Team members table (40 rows), and an Audit log that grows by millions of rows a month.
Users need to sort, filter, page or scroll, pick rows for bulk actions, resize and reorder columns, and fix a value in place. Walk me through the component's API, where sorting and filtering happen, how it stays fast, and how keyboard and screen reader users work with it.
Real-life examples: The payments list in the Stripe Dashboard, The instances list in the AWS EC2 console, Airtable's grid view, Report tables in Google Analytics, The issue navigator in Jira's list view
Requirements: what to ask before drawing anything
A data table shows records in rows and their fields in columns, and lets people work with them: sort by a column, filter down to what matters, page through the rest, pick some rows, and act on them. Every business app has dozens of them. That's why "design a data table" is a classic hard component question: the first screen takes ten minutes, and the rest of the hour is about the decisions that only show up at scale.
The word "table" hides very different products. A 40-row list of team members, a 250,000-row orders list, and a log with 50 million lines need different answers to the same questions. So the first job is to find out which tables we are building, and what each one needs. Ask, then write the answers down where the interviewer can see them.
| Question | Answer we assume | What it changes |
|---|---|---|
| How many rows can one table hold? | From 40 (Team members) to 250,000 (Orders) to tens of millions (Audit log). | Where sorting and filtering run. The browser can't download 250,000 orders, so big tables must ask the server. |
| How many columns? | Usually 6 to 15. A few admin tables have 60 to 80 (one column per product attribute). | Column virtualization, a sticky first column, and hiding columns. |
| Who uses the component? | About 30 product teams in one React app. Each wants its own cell content: avatars, status badges, money, links, menus. | A column definition API with custom cell renderers, not a fixed set of cell types. |
| Which features? | Sort, filter, paginate, select rows for bulk actions, resize, reorder and hide columns, edit a few cells in place, export to CSV. | Each one is a piece of state. We must decide who owns it: the table or the page. |
| Do people share links to a filtered view? | Yes. Support staff paste links like "orders that failed yesterday, newest first" into tickets. | Sort, filters, and page go in the URL. |
| Who uses it, on what? | Mostly desktop, but about 20% of visits are on phones (managers checking orders). Keyboard power users in support. Screen reader users must be able to do every task. | A phone layout, a full keyboard model, and the ARIA grid pattern. |
| Is the data live? | Orders change status every few seconds; the table may be a minute stale, with a refresh button. | No WebSocket per row. Refetch on focus and on demand. |
Functional requirements
- Show rows and columns from a column definition the calling team writes: a header, a way to read the value, and how to draw the cell.
- Sort by one column (click the header), and by several (Shift plus click adds a second key).
- Filter by column values (status is "failed", total above 100) and by a free text search box.
- Page through results with page numbers, or scroll through them in one long list, depending on the table.
- Select rows with checkboxes, select a range with Shift plus click, and select every row that matches the filter, not only the visible page.
- Resize, reorder, and hide columns, and remember those choices per user.
- Edit a few simple fields in place (a status, a note) and save them to the server.
- Export the current view to CSV.
- Show clear loading, empty, and error states.
Non-functional requirements, with numbers
- Fast to show. The first page of the Orders table appears within 1 second of navigation on a mid-range laptop, and changing the sort shows new rows within 500 ms on a normal office network.
- Smooth to use. Scrolling stays at 60 frames per second (about 16 ms per frame) with 100,000 rows loaded. A click on a header or a checkbox reacts within 100 ms, so INP (Interaction to Next Paint, the time from a click to the next frame on screen) stays well under the 200 ms "good" line.
- Accessible. WCAG 2.2 level AA. Every action works with a keyboard alone, and a screen reader announces the column, the row number out of the total, and the sort order.
- Small. The basic table (the headless core plus our rendering) under about 25 KB of compressed JavaScript. Heavier features such as virtualization, column drag, and editing load only in tables that use them, so a 40-row table doesn't pay for them.
- Shareable. Reloading or sharing the URL brings back the same sort, filters, and page.
Out of scope, and why
- A spreadsheet. Formulas, cell ranges, copy and paste of blocks, and every cell being editable are a different product (see the spreadsheet grid problem). Our edits are a few fields, one at a time.
- Pivot tables and charts. Grouping with totals across many levels is a reporting tool. We support simple grouping at most, as a follow-up.
- Real-time collaboration. Two people editing the same order at the same moment is rare here. We handle it with a version check on save, not live cursors.
Check your understanding · Exercise 1
Which answers from the interviewer change the design of the table itself, not just one page that uses it? Select all that apply.
What makes a data table hard
Before picking any technique, name the forces that push on this component. Every later chapter is an answer to one of them. A table looks static, but four things make it hard.
1. The data size spans six orders of magnitude
Team members: 40 rows. Orders: 250,000. Audit log: 50 million. One component has to serve all three.
For 40 rows the simplest design wins: download everything, sort in the browser, render every row. For 50 million rows the browser can hold almost nothing: it sees one page at a time and asks the server for each sort, filter, and page. Most of the design is about drawing the line between those two worlds, and letting one API work on both sides of it.
2. Cells multiply
Rows times columns is the number that hurts. 500 rows times 15 columns is 7,500 cells. A real cell is rarely one text node: a status badge, an avatar with a name, or a menu button adds 4 to 8 elements each. That's 30,000 to 60,000 DOM nodes for one screen of data.
Lighthouse's old DOM size audit warned at about 800 elements in the page body and failed above about 1,400. Since Lighthouse 13, the "Optimize DOM size" insight looks for slow style and layout work instead, but 1,400 is still a handy guideline. Here, every style change, every sort, and every resize touches tens of thousands of nodes, and the browser's layout work grows with them.
So a table that feels fine with 50 rows in a demo can freeze for a second when a real user loads 2,000. The fix is to render only what is on screen (virtualization), and to make sure one change re-renders as few cells as possible.
3. The state has many owners
Count the pieces of state: sort keys, filters, search text, page, page size, selected rows, column widths, column order, hidden columns, the cell being edited, and the cell that has keyboard focus. That's eleven, and each one wants a different home.
Sort and filters belong in the URL so links work. Column widths belong to the user, on the server or in local storage. Selection belongs to the page that runs the bulk action, and focus belongs to the table alone. A design that puts all of them in one private useState breaks links; a design that pushes all of them to the caller makes the 40-row table painful to use.
4. Mouse, keyboard, and screen reader all need the full story
A mouse user sees the whole screen and clicks anywhere. A keyboard user moves one cell at a time. A screen reader user hears one cell at a time and needs to know "row 1,204 of 250,000, column Status, sorted descending".
With virtualization, row 1,204 may be the only row near it in the DOM, so the table must tell assistive technology about rows that don't exist yet. That's what aria-rowcount and aria-rowindex are for, and it's a common place for designs to fail quietly.
| Force | What it decides | Chapter |
|---|---|---|
| Data size | Client or server for sort, filter, page; paging or infinite scroll | Where the data lives |
| Cells multiply | Virtualization of rows and columns; memoized cells | Rendering 100,000 rows |
| Many owners of state | Controlled and uncontrolled props; URL state; persistence | The component API, Where the data lives |
| Many input methods | Grid role, roving focus, row and column indexes | Keyboard and screen readers |
| 30 teams | Column definitions, renderers, headless core | The component API |
Think first
A team says: "Our table only has 300 rows, so none of this matters to us." Is that true?
Partly. 300 rows can be sorted and filtered in the browser in a few milliseconds, so the data-location question is easy for them.
But 300 rows times 12 columns with rich cells is still 15,000 to 25,000 DOM nodes. On a mid-range phone, the first render and every full re-render can take hundreds of milliseconds. They may still want virtualization, or at least paging, and they certainly want cells that don't all re-render when one checkbox changes.
Check your understanding · Exercise 2
A table renders 1,000 rows and 20 columns. Each cell holds on average 4 elements. Which statements are true? Select all that apply.
The component API: columns, cells, and who owns the state
Thirty teams will use this table. The API is the part they touch every day, and the part that is hardest to change later. Two questions shape it: how does a team describe its columns, and who owns each piece of state?
The naive API, and why it breaks
The first version most people write takes rows and a list of columns with a key and a title, and prints row[key] in each cell. It works for the Team members demo.
Then the Orders team needs the customer's name from order.customer.name, a money column formatted in the order's currency, a status badge, and a link. Someone adds a type: "money" option, then type: "badge", then badgeColors, then linkHref. After a year the props list is 90 options long and still can't draw what the next team wants. This is the "configuration explosion" that the component API design lesson warns about.
The cure is to split each column into three small, general ideas instead of many special ones: how to read the value, how to draw it, and how to compare it.
Column definitions: accessor, renderer, and behavior
- Accessor: a key (
"total") or a function that takes the row and returns the raw value (for exampleorder.customer.name). Sorting, filtering, and CSV export all use this raw value, never the drawn cell. - Cell renderer: a function that gets the value and the whole row and returns what to draw. Teams can return a badge, a link, or a menu. The table doesn't need to know about any of them.
- Behavior flags: can it sort, can it be filtered, a custom compare function, a default and minimum width, whether it can be hidden, whether it's pinned to the left.
type ColumnDef<Row, Value> = {
id: string; // stable, used in URL and saved layouts
header: string | (() => ReactNode);
accessor: keyof Row | ((row: Row) => Value);
cell?: (ctx: { value: Value; row: Row }) => ReactNode;
sortable?: boolean;
compare?: (a: Value, b: Value) => number; // client mode only
filter?: "text" | "select" | "range"; // which filter UI to show
width?: number; // px, default 160
minWidth?: number; // px, default 60
pinned?: "left";
hideable?: boolean;
exportValue?: (row: Row) => string; // CSV text
};
const columns: ColumnDef<Order, unknown>[] = [
{ id: "id", header: "Order", accessor: "id", pinned: "left", width: 120 },
{ id: "customer", header: "Customer",
accessor: (o) => o.customer.name, sortable: true, filter: "text" },
{ id: "total", header: "Total", accessor: "totalCents", sortable: true,
cell: ({ value, row }) => <Money cents={value as number} currency={row.currency} />,
exportValue: (o) => (o.totalCents / 100).toFixed(2) },
{ id: "status", header: "Status", accessor: "status", filter: "select",
cell: ({ value }) => <StatusBadge status={value as OrderStatus} /> },
];
Notice that total stores cents. The accessor returns the number 129900, so sorting compares numbers, not the formatted strings "$1,299.00" and "$999.00" (which sort the wrong way as text). The renderer formats for display, and exportValue formats for CSV. Keeping the raw value separate from the drawn value is the single most useful rule in table APIs.
Controlled or uncontrolled: who owns sort, filters, page, and selection?
React components can own their state (uncontrolled: the table keeps sort order in its own useState) or receive it from the caller (controlled: the caller passes sorting and gets onSortingChange when the user clicks a header). The Team members page wants the first: drop the table in and it just sorts. The Orders page wants the second: sorting must go into the URL and into the server request, so the page has to own it.
The answer is to support both, per piece of state, the same way a native input supports value and defaultValue. If the caller passes sorting, the table treats it as the truth and only reports changes. If not, the table keeps its own copy, starting from defaultSorting. Each feature (sorting, filters, pagination, selection, column layout) decides this on its own, so the Orders page can control sorting and filters through the URL while letting the table keep column widths privately.
function OrdersPage() {
const [query, setQuery] = useUrlTableState(); // sort, filters, page from the URL
const { data, isFetching, error } = useOrders(query); // server state library
return (
<DataTable
columns={orderColumns}
rows={data?.rows ?? []}
rowCount={data?.total} // tells the table about rows it doesn't have
getRowId={(o) => o.id}
mode="server" // don't sort or filter locally
sorting={query.sort}
onSortingChange={(sort) => setQuery({ ...query, sort, page: 1 })}
filters={query.filters}
onFiltersChange={(filters) => setQuery({ ...query, filters, page: 1 })}
pagination={{ page: query.page, pageSize: 50 }}
onPaginationChange={(p) => setQuery({ ...query, page: p.page })}
loading={isFetching}
error={error}
layoutKey="orders" // saved widths and order, per user
/>
);
}
sorting is passed, the table only reports clicks through onSortingChange; if not, it keeps its own copy, starting from defaultSorting.Build it, wrap a headless core, or buy a full grid?
Decision What do we build the table on?
What we pick: Wrap a headless core in our own DataTable component. Sorting, selection, and sizing logic are solved problems; our design system's look, ARIA, and keyboard model are not something a vendor can own for us. Teams never import the library directly, so we can swap it later. For one or two power-user screens that truly need spreadsheet editing or pivoting, a full grid can be a separate, lazily loaded exception.
Check your understanding · Exercise 3
The Orders page passes sorting and onSortingChange, but forgets to update its state inside onSortingChange. What happens, and what does it tell you about the API? Select all that apply.
Where the data lives: client or server, and the URL
Sorting, filtering, and paging are three operations on one list. The big question is where they run. In client mode the browser downloads every row once and does all three in JavaScript. In server mode the browser sends the sort, filters, and page to an API and gets back only the rows to show, plus a total count.
The naive approach: always download everything
Client mode is lovely when it fits. Sorting is instant, filters react on every keystroke, and there are no loading spinners. So the first version fetches /api/orders and sorts in the browser.
Now do the math for Orders. One order as JSON, with customer name, email, totals, status, and dates, is about 400 bytes. 250,000 orders is about 100 MB of JSON. JSON often compresses 7 to 10 times with gzip or Brotli (an estimate that depends on the data), so it's still around 10 to 15 MB to download.
Then the browser must parse it and hold 250,000 objects in memory, likely a hundred megabytes or more of heap. The page takes many seconds to show anything, and on phones the tab may crash.
Server mode for everything has the opposite problem. The 40-row Team members table now makes a network request for each header click. Every sort shows a spinner for 200 ms for data that was already in the browser.
Decision Where do sort, filter, and pagination run?
What we pick: Support both modes, chosen per table. Team members runs in client mode. Orders and Audit log run in server mode. The threshold is a guideline for teams, not magic: around a few thousand rows or a couple of megabytes of JSON, client mode stops being free. Make the server the single source of the sort order for anything that might ever grow.
Finding the crossover point
The crossover is where the one-time cost of client mode (download and parse every row) becomes larger than what users would spend waiting on the server over a session. Play with the numbers below: rows, bytes per row, the network speed, and how many sort or filter changes a user makes in a session.
The calculator uses rough estimates, not measurements: 8 times compression, 10 ms to parse each megabyte of JSON, 0.6 ms to sort 1,000 rows, and 250 ms per server request. It counts only waiting time; memory and freshness, below, push toward server mode sooner.
Three things move the crossover more than raw row count. First, row width: a 30-column row with long text can be 2 KB, which divides the threshold by five. Second, the network: at 1.5 Mbps (a slow mobile connection), each compressed megabyte takes over 5 seconds. Third, freshness: if rows change every few seconds, a client copy is wrong soon after it arrives.
In practice, a few thousand rows of 10 to 15 columns is comfortable for client mode. Above roughly 10,000 rows, server mode is usually the safer choice, even where the calculator still favors the client on a fast network.
Server mode, step by step
Here is what happens when a user clicks the Total header on the Orders page, including what happens if a slower, older request comes back late or the server fails.
AbortController stops an older, slower request from winning.- The header's click handler calls
onSortingChange([{ id: "total", desc: true }]). The header shows the new arrow at once, so the click feels answered within one frame. - The page writes
?sort=-total&page=1to the URL. The query key changes, so the server state library starts a request and aborts the previous one withAbortController. - While waiting, the table keeps the old rows on screen, dimmed, with a thin progress bar. Swapping them for a skeleton would make the page jump on every sort.
- The response arrives and the rows are replaced. Focus stays on the header the user clicked.
- If the request fails, the old rows stay, the header goes back to the old sort, and an inline error says "Couldn't sort. Try again." The URL goes back too, so it never shows a state the table isn't in.
The search box adds one more rule. Each keystroke must not send a request, so the filter is debounced by about 300 ms (see the debounce and throttle lesson), and each new request aborts the one before it. Without the abort, the answer for "ac" can arrive after the answer for "acme" and overwrite it: a classic request race.
Pages, cursors, and counts
Server mode needs a pagination style. Offset paging (page=40&pageSize=50) lets users jump to any page and show "Page 40 of 5,000", which suits Orders. It gets slow on very deep pages and can skip or repeat rows when new orders arrive.
Cursor paging (after=opaque-token) is stable and fast at any depth, which suits the Audit log, where people scroll forward through time. The pagination lesson covers both in depth.
Counting can be the slowest part. An exact COUNT over 50 million filtered rows can take seconds on many databases. For the Audit log, show "1 to 50 of many" or an estimate such as "about 1.2M", and offer a Next button instead of page numbers.
Sort, filters, and page in the URL
Support staff share filtered views, so the URL is the home of the query. A short, readable format works best: /orders?sort=-createdAt,customer&status=failed,refunded&q=acme&page=3. A minus sign means descending; commas join multiple values, which is safe here because column ids and status values never contain commas. The url-as-state lesson covers the general pattern; three rules matter most for tables.
- Validate on read. Only accept column ids that exist and are sortable, and clamp
pageto a sane range. A hand-edited?sort=passwordmust be ignored, not sent to the server as-is. - Push or replace on purpose. A sort, a filter change, or a new page is a step the user may want to undo with Back, so use a new history entry. Each keystroke in the search box replaces the current entry, or Back would replay every letter.
- Leave out defaults. If the default sort is newest first, don't write it. Short URLs are easier to share, and a changed default doesn't break old links.
Check your understanding · Exercise 4
The Orders table runs in server mode. Which of these are real bugs that the design above prevents? Select all that apply.
Rendering 100,000 rows: virtualization, sticky header, sticky column
Server paging keeps the DOM small: 50 rows per page. But some tables must scroll. The Audit log is read like a stream, and a few teams load 20,000 rows into client mode on purpose so filtering is instant.
For those, the table needs virtualization (also called windowing): render only the rows that are on screen, plus a few spare ones, and fake the rest of the height so the scrollbar looks right. The virtualization lesson builds the idea from zero; here we focus on what's special about tables.
Decision How do we keep a long table fast?
What we pick: Default to paging for server-mode tables (it matches how people work through Orders). Turn on row virtualization when a table either scrolls a stream (Audit log) or holds more than about 500 rows in client mode. Turn on column virtualization only above about 30 columns, because it complicates sticky columns and resizing.
The window math
With a fixed row height, the math is a few lines. Say rows are 40 px tall, the viewport is 800 px, and there are 100,000 rows. The full height is 4,000,000 px.
When the user has scrolled 120,000 px down, the first visible row is 120,000 / 40 = 3,000, and 800 / 40 = 20 rows fit on screen (rows 3,000 to 3,019). With an overscan of 6 on each side, the table renders rows 2,994 to 3,025: 32 rows, no matter where you are.
const ROW_H = 40;
const OVERSCAN = 6;
function visibleRange(scrollTop: number, viewportH: number, rowCount: number) {
const first = Math.floor(scrollTop / ROW_H);
const visible = Math.ceil(viewportH / ROW_H);
const start = Math.max(0, first - OVERSCAN);
const end = Math.min(rowCount - 1, first + visible - 1 + OVERSCAN);
return {
start,
end,
padTop: start * ROW_H, // spacer above the rendered rows
padBottom: (rowCount - 1 - end) * ROW_H, // spacer below them
};
}
Fixed heights are a gift: no measuring and no scroll jumps. Ask the design team for one row height per density (compact 32 px, normal 40 px, relaxed 52 px) and truncate long text with an ellipsis and a tooltip.
If rows must wrap, measure each rendered row with a ResizeObserver, keep a running list of offsets, and use an estimate for rows not yet seen. When a measured row turns out taller than the estimate above the viewport, adjust the scroll position by the difference so the content under the user's eyes doesn't move.
position: sticky, so they never lag behind the body.Sticky header and sticky first column
Two things must never scroll away: the header row (so you know what each column means) and the first column (so you know which order you're looking at after scrolling 40 columns to the right). CSS position: sticky does both without JavaScript: top: 0 on header cells, left: 0 on cells of the pinned column. The top-left corner cell is sticky in both directions and sits on the highest layer.
- Stick the cells, not the rows. Put
position: stickyon thethandtdelements, which every current browser engine supports. Sticky ontheadonly arrived in Chrome 91, and sticky ontris still unreliable. - One scroll container. The header, the body, and the pinned column must live inside the same element that scrolls in both directions. Two separate scrollers (one for the header, one for the body) drift apart by a frame while scrolling and need JavaScript to keep them in sync.
- No clipping ancestors in between. An ancestor with
overflow: hiddenbetween the sticky cell and the scroller becomes the new scroll box, and the cell stops sticking. This is the most common "sticky doesn't work" bug. - Layers. Body cells at the base, pinned column above them, header above that, the corner cell on top. Give sticky cells a solid background, or scrolled content shows through.
- Several pinned columns. The second pinned column's
leftis the first column's width. When the user resizes the first column, the offsets of the others must update too. - Column virtualization plus pinning. The virtualizer windows only the unpinned columns. Pinned columns are always rendered, and the horizontal spacer starts after them.
Rows positioned with transform: translateY(...) scroll smoothly because the browser can move them without new layout. Sticky cells inside translated rows usually keep working, because sticking is measured against the shared scroller. Still, test this combination in Chrome, Firefox, and Safari: sticky positioning inside tables has had more engine differences than most CSS features.
Check your understanding · Exercise 5
Users report: "In the 20,000-row Products table, Ctrl+F doesn't find a product I know is there." The table uses row virtualization. What are good responses? Select all that apply.
Columns people can shape: resize, reorder, hide, and remember
Support agents live in the Orders table all day. They want the Customer column wide, the Notes column hidden, and Status right after the order number. Column layout is personal state: it belongs to one user, survives reloads, and should never end up in a shared link.
Resizing: the naive version and its four bugs
The first attempt listens for mousedown on the header border and mousemove on the header. It breaks in four ways.
On a phone, a finger drag fires no mouse move events, so nothing resizes. If the pointer moves faster than the header redraws, it leaves the header and the drag stops. If the user lets go outside the header, the header never hears mouseup and the column keeps following the cursor. And every mousemove calls setState, so every rendered cell re-renders on each move, up to 60 times a second or more.
- Use Pointer Events. One set of
pointerdown,pointermove, andpointeruphandlers covers mouse, touch, and pen. Addtouch-action: noneon the handle so a finger drag resizes instead of scrolling the page. - Capture the pointer. On
pointerdown, callhandle.setPointerCapture(event.pointerId). Every later move and the finalpointerupgo to the handle, even when the pointer leaves it or the window. - Clamp. New width = start width + (current x minus start x), then
Math.max(minWidth, ...). A minimum of about 60 px keeps the header label and sort arrow readable. In a right-to-left layout, the sign flips. - Don't re-render cells while dragging. Write the width into a CSS custom property on the table (for example
--col-customer: 240px) inside arequestAnimationFrame. Cells already usewidth: var(--col-customer), so the browser updates the layout without React touching a single cell. Save the final width to state once, onpointerup.
pointerup. During the drag only a CSS variable changes, inside requestAnimationFrame, so no cell re-renders. Pointer capture keeps the drag alive even when the pointer leaves the window.A double-click on the handle can auto-fit the column to its content. With virtualization, only the rendered rows can be measured, so auto-fit means "fit what's on screen". That's usually what users expect; say it out loud in the interview rather than promising a perfect fit for 100,000 rows.
Resizing without dragging
WCAG 2.2 added success criterion 2.5.7, Dragging Movements (level AA): anything done by dragging must also be possible with a single pointer without dragging, such as clicks. Keyboard support alone doesn't meet it. So each column gets a header menu with "Wider", "Narrower", and "Fit to content" items. The resize handle itself is also focusable: it can use the separator role with aria-valuenow, like the window splitter pattern in the ARIA Authoring Practices, and Left and Right arrows change the width by 10 px (50 px with Shift).
Reordering columns
Decision How do users move a column?
What we pick: Pointer-driven drag for speed, plus the header menu and Columns panel as the always-available alternative. After a move, announce it in a polite live region: "Status moved to position 2 of 12." Pinned columns can only move among pinned columns, so the order number never ends up in the middle of the scroll area.
Remembering the layout
Store a small layout object per user and per table: { version, order, widths, hidden, pinned }, under a key like orders. Write it to localStorage right away so a reload is instant, and save it to the server (debounced by a second or two) so it follows the user to another laptop.
On load, merge it with the current column definitions: drop ids that no longer exist, and add new columns at their default position. Without that merge, shipping a new column makes it invisible for every user who ever resized anything.
Check your understanding · Exercise 6
Which choices make column resizing work well? Select all that apply.
Selecting rows and editing cells
Selection exists for bulk actions: refund these 12 orders, export those, assign all failed orders from yesterday to one agent. Editing exists for small fixes: change a status, add a note. Both look simple and both hide traps.
Select by id, never by position
The naive table stores selected row indexes: rows 0, 3, and 7. Then the user sorts, and rows 0, 3, and 7 are now three different orders; a bulk refund on the wrong orders is a real incident, not a cosmetic bug. Store selected row ids in a Set, using the getRowId function the caller passes. Ids survive sorting, paging, and refetching.
Shift plus click: ranges
Clicking a checkbox sets the anchor: the row the next range starts from. Shift plus click on another row selects every row between the anchor and that row, in the order the user sees right now (after sorting and filtering). The range takes the state of the clicked row: if it becomes selected, the whole range is selected.
In server mode with pages, a range covers only the loaded page; if the anchor sits on another page, start a new range instead. On the keyboard, Space toggles the focused row and Shift plus Space selects it (the convention in the ARIA Authoring Practices grid pattern). Shift plus Up or Down Arrow moves focus and extends the selection from the anchor.
The header checkbox and "select all"
The checkbox in the header has three looks: empty (nothing on this page selected), checked (all of this page selected), and indeterminate (some selected), drawn as a dash. On a native checkbox, indeterminate can only be set from JavaScript with input.indeterminate = true; there is no HTML attribute for it. Most screen readers then announce it as "mixed" or "half checked". A custom checkbox would use aria-checked="mixed" instead.
What does "select all" mean when 1,284 orders match and 50 are on screen? Gmail answered this years ago, and users know the pattern: clicking the header checkbox selects the 50 on the page, then a bar appears: "All 50 on this page are selected. Select all 1,284 matching orders." Choosing it switches the selection into a different shape: instead of a list of ids, it's "everything matching this filter, except these".
type Selection =
| { kind: "ids"; ids: Set<string>; anchorId?: string }
| {
kind: "allMatching";
filters: OrderFilters; // snapshot of the query when chosen
total: number; // what the user was told: 1,284
excludedIds: Set<string>; // rows unticked afterwards
};
// Bulk action request
// POST /api/orders/bulk-refund
// { "selection": { "kind": "allMatching",
// "filters": { "status": ["failed"], "createdAfter": "2026-10-19" },
// "excludedIds": ["ord_88"], "expectedCount": 1284 } }
- Clear it when the filters change. If the user changes the filter or the search after choosing "all matching", the old selection no longer means what they saw. Clear it, and say so. A new sort or page doesn't change which rows match, so it keeps the selection.
- Send the filter, not the ids. The server re-runs the filter when the action runs. If the count differs from
expectedCount(new orders arrived), it can ask for a confirmation instead of acting on rows the user never saw. - Show the count. A sticky action bar says "1,283 selected" with the actions. People must always know how many rows a destructive action will touch.
Check your understanding · Exercise 7
The user picks "Select all 1,284 matching orders", then unticks 2 rows, then clicks Refund. Which statements are true? Select all that apply.
Editing a cell in place
Inline editing is for a few fields, one cell at a time. The interaction follows spreadsheet habits: Enter or F2 (or a double-click) starts editing the focused cell, Enter saves, Escape cancels and restores the old value, Tab saves and moves to the next editable cell. While a cell is editing, arrow keys move the text cursor, not the grid focus.
- Optimistic save. Show the new value at once and send
PATCH /api/orders/ord_42in the background, with a small "saving" mark on the cell. - Version check. Send the version you edited (an
If-Matchheader with the row's ETag, or aversionfield). If another agent changed the order meanwhile, the server answers412 Precondition Failed(or409 Conflict), and you show both values instead of silently overwriting theirs. - Roll back on failure. On an error, put the old value back, mark the cell, and keep the typed text available so the user can retry without retyping.
- Don't let the row jump. If the table is sorted by Status and the user changes a status, moving the row to its new sorted place would yank it from under the cursor. Keep it where it is until the next refresh or sort.
- Keep the editing row mounted. With virtualization, an editing row that scrolls far away would unmount and lose its input. Either keep it rendered while editing or save on blur.
If-Match header turns a silent overwrite into a visible conflict, and the draft survives so the user can retry without retyping.Think first
Why does the table need a version check if the Orders page is "a minute stale at most"?
A minute is plenty of time for two agents to open the same failed order. Without a check, the second save silently replaces the first: "last write wins", and nobody knows a change was lost.
The version check turns that silent loss into a visible conflict. The cost is one header on the request and one error state in the cell.
Keyboard and screen readers: table or grid?
A screen reader user opens the Orders page. They should be able to hear "Orders table, 250,000 rows, 9 columns", move through cells and hear each column name, learn that the table is sorted by Created date, newest first, select three orders, and run a bulk action. A keyboard user should do all of it without a mouse, and without pressing Tab 400 times. This chapter covers the structure; the aria-patterns and keyboard-navigation lessons cover the general rules.
Two different patterns
A plain HTML table with th scope="col" headers is already accessible. Screen readers have table commands (move to the next cell, row, or column) and read the column header as you move. Links and buttons inside cells are reached with Tab. This is perfect for a read-only table with one link per row.
The ARIA grid pattern (role="grid", rows with role="row", cells with role="gridcell") turns the whole table into one widget with one Tab stop. Arrow keys move between cells, like a spreadsheet. Screen readers switch into a mode where keys go to the page instead of their own reading commands, so the grid must handle every key itself. It fits when cells are interactive: checkboxes, editable cells, row menus.
Decision Which keyboard and screen reader model does the table use?
What we pick: Two modes in one component. Read-only tables with at most one link per row render as a plain table. Tables with selection, editing, or row actions render as a grid with roving tabindex, which keeps real focus and is the pattern the ARIA Authoring Practices data grid examples use. Both keep the native table elements underneath, with the grid adding role="grid" on top.
The keyboard map
| Key | What it does in our grid |
|---|---|
| Tab | Moves into the grid (to the last focused cell) or out of it. Never cell to cell. |
| Arrow keys | Move one cell up, down, left, or right. Stop at the edges (no wrapping). |
| Home / End | First or last cell in the row. |
| Ctrl + Home / Ctrl + End | First cell of the first row, or last cell of the last row (loading it if needed). |
| Page Up / Page Down | Move by one screen of rows (about 20). |
| Space | Toggle selection of the focused row and make it the anchor. |
| Shift + Space | Select the focused row, the shortcut the APG grid pattern suggests for a checkbox column. |
| Shift + Up / Shift + Down | Move one row and extend the selection from the anchor. |
| Enter or F2 | On a header: sort. On an editable cell: start editing. On a cell with one button or link: activate it. |
| Escape | Cancel editing, or leave a widget inside a cell and return to cell navigation. |
Try it below. Click a cell (or Tab into the grid), use the arrow keys, Space to toggle a row, and Shift plus Down Arrow to extend a range. Watch what a screen reader would be told: the row index out of the full total, even though only five data rows exist in the DOM. Sort by Total and the selection stays, because it is stored by order id.
| Select | Order | Customer | Total (high first) |
|---|---|---|---|
| 586962 | Bolt Co | 9853.28 | |
| 594881 | Cora GmbH | 9853.24 | |
| 602800 | Dune Inc | 9853.20 | |
| 610719 | Elm and Sons | 9853.17 | |
| 618638 | Fable SA | 9853.13 |
Demo readout: Click a cell, then use the arrow keys, Home, End, Page Up, Page Down, Space, Shift plus Space, and Shift plus Down Arrow.
Virtualization and the rows that don't exist
Screen readers count rows from the DOM. With 30 rendered rows out of 250,000, they would say "30 rows", which is false. ARIA fixes this with four attributes.
Put aria-rowcount="250001" on the table (all rows plus the header row; use -1 if the total is unknown) and aria-rowindex on each rendered row, counting from 1 with the header as row 1. Do the same with aria-colcount and aria-colindex when columns are virtualized. Now the reader says "row 1,204 of 250,001".
- Keep the focused row mounted. If the user focuses row 1,204 and then scrolls with the mouse, the virtualizer would remove that row and focus would fall to the page body. Always include the focused row in the rendered set, even when it's off screen.
- Scroll before focusing. When arrow down moves to a row that isn't rendered yet, scroll so it renders, then move focus in the next frame.
- Busy state. Set
aria-busy="true"on the table while a new page or sort loads, and announce the result in a polite live region: "Sorted by Total, descending. 1,284 results."
Announcing sort and selection
Put aria-sort="ascending" or "descending" on the th of the sorted column; the ARIA spec asks authors to set it on one header at a time. Sortable columns that aren't sorted can leave it off (as the APG sortable table example does) or use "none" (as its data grid example does).
For a multi-column sort, mark the primary column and describe the rest in the header's text for screen readers ("Customer, secondary sort, ascending"). Inside the header, the clickable part is a real button, so it is reachable and announced as a button.
For selection, each row checkbox gets a label that names the row ("Select order 4521"). In grid mode the row also gets aria-selected, with aria-multiselectable="true" on the grid.
Check your understanding · Exercise 8
A virtualized grid shows rows 1,200 to 1,230 of 250,000. Which markup is correct? Select all that apply.
Architecture: the pieces and where their boundaries are
With the big decisions made, draw the parts. Two rules shape the boundaries. First, the table never fetches data: the page does, so the same table works with REST, GraphQL, or an array in memory. Second, logic and drawing are separate: a headless core turns state plus rows into a "view model" (which rows, in which order, which columns, how wide), and thin components draw it.
| Part | Lives in | Responsibility |
|---|---|---|
useUrlTableState | Page | Reads and validates sort, filters, search, and page from the URL; writes them back with push or replace; resets the page on query change. |
| Server state library (for example TanStack Query) | Page | Fetches a page for a query key, caches recent pages, aborts stale requests, keeps the previous page on screen while loading, refetches on window focus. |
| API client | Shared | Builds the request URL, attaches auth, parses the response and the error format. |
DataTable shell | Design system | Takes columns, rows, state, and callbacks. Chooses table or grid mode. Lays out toolbar, header, body, footer. |
| Table core (headless) | Design system | Holds uncontrolled state when the caller doesn't. Computes the row model: sorted, filtered, paged rows in client mode, or rows as given in server mode. |
| Header | Design system | Sort buttons with aria-sort, resize handles, header menus, drag to reorder. |
| Virtualizer and body | Design system | Works out which rows and columns to render, writes spacers, keeps the focused row mounted, sets aria-rowindex. |
| Row and cells | Design system plus team renderers | Memoized row component; calls each column's renderer with the value and the row. |
| Focus controller | Design system | Roving tabindex, the keyboard map, entering and leaving edit mode. |
| Selection bar, pagination, status layer | Design system | Bulk actions with a count; page controls; loading, empty, and error states. |
| Layout store | Design system | Column order, widths, hidden, pinned; localStorage now, server later. |
| Live region | Design system | Polite announcements for sort, result counts, moves, and save errors. |
Why does the URL hook live in the page and not in the table? Because a page can hold two tables, and only one of them should own the URL. Why does the layout store live in the table? Because every team wants it, and no team should have to write it.
Why is the server state library outside? Because caching, retries, and refetch rules are app-wide decisions, and the table should work with whatever the app uses.
How data moves end to end
Follow one view of the Orders page from the server to the screen and back. Reads flow down: URL to query key to request to cache to rows to cells. User actions flow up as callbacks, and change the URL or send a write; nothing in the table writes to the server directly.
PATCH plus an optimistic cache update.- The URL
?status=failed&sort=-total&page=2is parsed into a query object. - The query object is the cache key. On a cache miss, the API client sends
GET /api/orders?status=failed&sort=-total&page=2&pageSize=50through the CDN or backend-for-frontend to the orders service. - About 20 KB of JSON comes back (a few KB after compression): 50 rows, the total, and the server's applied sort and filters.
- The cache stores the page. Going back to page 1 now shows instantly from cache while a background refetch checks it.
- The table core builds the row model. The virtualizer picks rows, and each memoized row renders its cells through the team's renderers.
- A click on a header or a filter flows up as
onSortingChangeoronFiltersChange, which writes the URL, which starts the loop again. An inline edit flows up asonCellEdit, which the page turns into aPATCHand an optimistic cache update.
Check your understanding · Exercise 9
Why does the design keep data fetching out of the DataTable component? Select all that apply.
Data model: every piece of state and its home
Chapter 2 counted eleven kinds of state. Now give each one a home. The rule of thumb: put state where its lifetime and its audience are.
If it should survive a shared link, it goes in the URL. If it should follow one user, it goes in their saved preferences. If it is a copy of server data, it goes in the server cache. If only the table cares, the table keeps it.
| State | Home | Why there |
|---|---|---|
| Sort, filters, search, page | URL | Shareable, survives reload, Back undoes it. |
| Page size | User preferences | Personal; a shared link shouldn't force 200 rows on a phone. |
| Rows and total | Server state cache, keyed by query | A copy of server data; refetched, deduplicated, and updated after edits. |
| Column order, widths, hidden, pinned | localStorage plus user preferences on the server | Personal, long-lived, follows the user to other devices. |
| Selection | Page component state (controlled) or table state | Lives as long as the current filters and search; cleared when they change. |
| Focused cell, editing cell, draft value | Table internal state | Nobody else needs it, and it changes on every key press. |
| Pending edits | Server state cache (optimistic) plus a per-cell status | So every view of the order shows the new value, and errors can roll back. |
| Resize in progress | A CSS variable, not React state | Changes 60 times a second; only the final width is committed. |
type SortKey = { id: string; desc: boolean };
type TableQuery = { // lives in the URL
sort: SortKey[]; // ?sort=-total,customer
filters: Record<string, string[]>; // ?status=failed,refunded
search: string; // ?q=acme
page: number; // ?page=3 (offset mode)
cursor?: string; // ?after=... (cursor mode)
};
type PageResult<Row> = { // lives in the server cache
rows: Row[];
total: number | null; // null when counting is too costly
nextCursor?: string;
appliedSort: SortKey[]; // what the server actually did
};
type ColumnLayout = { // localStorage + preferences API
version: 1;
order: string[];
widths: Record<string, number>;
hidden: string[];
pinned: string[];
};
type CellEditState = // table state, per cell being saved
| { status: "editing"; draft: string }
| { status: "saving"; value: unknown }
| { status: "error"; draft: string; message: string };
Two fields deserve a note. appliedSort lets the server say "I sorted by created date, because sort=password is not allowed", and the header shows what really happened. total can be null for the Audit log, where counting is too slow; the pagination then shows Next and Previous only.
Check your understanding · Exercise 10
Where should these live? Select all that apply.
Interface: the API and the component's props
A component question still has an API: the server endpoints the page calls, and the props and events teams use. Design both, because the server shapes decide what the table can promise.
Listing rows
GET /api/orders?status=failed,refunded&q=acme&sort=-total,customer&page=3&pageSize=50
Accept: application/json
200 OK
Cache-Control: private, max-age=0, must-revalidate
{
"rows": [
{ "id": "ord_4521", "customer": { "id": "cus_9", "name": "Acme Ltd" },
"totalCents": 129900, "currency": "EUR", "status": "failed",
"createdAt": "2026-10-19T08:14:03Z", "version": 7 }
],
"total": 1284,
"page": 3,
"pageSize": 50,
"appliedSort": [{ "id": "total", "desc": true }, { "id": "customer", "desc": false }]
}
- Raw values, not formatted ones.
totalCentsand an ISO date, not "€1,299.00" or "yesterday". The client formats withIntl.NumberFormatandIntl.DateTimeFormatin the user's locale, and sorting stays correct. - A stable tie-breaker. Many orders share a status or a total. The server always adds the id as the last sort key, so page 3 never repeats a row from page 2.
- Errors in one shape.
{ "error": { "code": "invalid_filter", "message": "...", "field": "status" } }with a 400, so the table can point at the broken filter.
Writes: edit, bulk action, export
PATCH /api/orders/ord_4521
If-Match: "7"
{ "status": "refunded" }
-> 200 { ...order, "version": 8 }
-> 412 { "error": { "code": "version_conflict", "current": { ...order } } }
POST /api/orders/bulk
{ "action": "assign", "assignee": "usr_3",
"selection": { "kind": "allMatching", "filters": { "status": ["failed"] },
"excludedIds": ["ord_88"], "expectedCount": 1284 } }
-> 202 { "jobId": "job_51", "count": 1284 }
POST /api/orders/exports
{ "format": "csv", "filters": { "status": ["failed"] }, "sort": "-total",
"columns": ["id", "customer", "total", "status"] }
-> 202 { "exportId": "exp_7" } // later: GET /api/orders/exports/exp_7 -> download URL
The component's props and events
| Prop or event | Type | Notes |
|---|---|---|
columns | array of column definitions | Accessor, renderer, sort and filter settings, widths, pinning. |
rows, getRowId | array, function returning a string | Ids keep selection and focus stable across sorts and refetches. |
mode | "client" or "server" | Server mode shows rows as given and only reports state changes. |
rowCount | number or null | Total in server mode; drives pagination and aria-rowcount. |
sorting / defaultSorting / onSortingChange | sort keys | Controlled, uncontrolled, and the change callback. Same trio for filters, pagination, selection, and column layout. |
loading, error, emptyState | boolean, error, node | Drive the status layer; the table keeps old rows while loading. |
onCellEdit | (row id, column id, value) returning a promise | The table shows saving, success, or error from the promise. |
onRowActivate | (row id) | Enter on a row, or a click on its primary link. |
layoutKey | string | Turns on saved layouts for this user. |
density | "compact", "normal", "relaxed" | Sets the fixed row height that virtualization relies on. |
virtualize | boolean or "auto" | Auto turns it on above about 500 rows. |
caption | string | The table's accessible name, visible or visually hidden. |
Notice what is missing: no fetchUrl, no onScrollEnd that loads data by itself, no theme props beyond density. Every prop is either data, state with its change event, or a rendering hook. That small surface is what lets 30 teams use one component for years without it turning into a settings panel. When a team asks for something new, the first question is whether a column renderer or a controlled prop already covers it.
Check your understanding · Exercise 11
Why does the server always add the row id as the last sort key? Select all that apply.
Performance: re-render as few cells as possible
Virtualization limits how many cells exist. The next question is how many of them re-render on each interaction. Take a virtualized grid with 32 rendered rows and 15 columns: 480 cells.
If pressing the down arrow re-renders all 480, each with a badge or a formatted number, that can be 10 to 30 ms of React work on a laptop and much more on a mid-range phone. Hold the key down and the focus lags behind. The goal: an arrow key re-renders 2 rows (the one losing focus and the one gaining it), and a checkbox re-renders 1 row plus the header.
Memoized rows with narrow props
- Wrap the row in
React.memo. It skips rendering when its props are unchanged by shallow comparison. - Pass booleans, not collections. Don't pass the whole
selectedIdsset to every row: when it changes, every row sees a new prop. PassisSelectedto each row, so only rows whose answer changed re-render. - Pass focus narrowly. Each row gets
focusedColumnIdonly if it's the focused row, andnullotherwise. Moving focus down changes the prop on exactly two rows. - Keep row objects stable. A server state cache that keeps unchanged objects the same between refetches (TanStack Query calls this structural sharing) means a refetch that changed one order re-renders one row.
- Stable callbacks and columns. Column definitions created inside the page's render make a new array every time and break every memo. Define them outside the component or in
useMemo. Pass callbacks that read the row id from the event instead of creating a new closure per row.
const Row = memo(function Row({ row, columns, isSelected, focusedColumnId, rowIndex }: RowProps) {
return (
<tr aria-rowindex={rowIndex} aria-selected={isSelected}>
{columns.map((col) => (
<Cell key={col.id} col={col} row={row} focused={col.id === focusedColumnId} />
))}
</tr>
);
});
// In the body: one boolean and one id per row, not the whole Set
{range.map((i) => {
const row = rows[i];
const id = getRowId(row);
return (
<Row key={id} row={row} columns={visibleColumns} rowIndex={i + 2}
isSelected={selected.has(id)}
focusedColumnId={focus.rowId === id ? focus.columnId : null} />
);
})}
The React Compiler (stable since late 2025) can add much of this memoization automatically, but it can't fix props that are truly new each time, like a whole Set passed to every row. Narrow props are a design choice, not just an optimization.
Client mode at 20,000 rows
- Create one
Intl.Collatorand reuse it. Callinga.localeCompare(b, locale)inside the compare function can build locale data on every call; a sharednew Intl.Collator(locale, { numeric: true })compares strings much faster and sorts "Item 2" before "Item 10". - Keep typing smooth. Filtering 20,000 rows on each key press can take tens of milliseconds. Mark the filtered result as low priority with
useDeferredValue(orstartTransition), so the input updates first and the table catches up. - Move it off the main thread when it's big. Beyond roughly 50,000 rows or heavy text search, run sort and filter in a Web Worker and send back the ordered list of ids, not the row objects.
Layout and paint
table-layout: fixedwith explicit column widths. In the default auto layout, the browser reads all cell contents to size columns, and widths jump as new rows scroll in.- Fixed row heights per density, so the virtualizer never measures and the scroll position never jumps.
- No layout reads in scroll handlers. Read
scrollToponce per frame, compute the range, and write. ReadingoffsetHeightafter writing styles forces the browser to lay out the page again, every frame. - Watch INP in the field. Real-user monitoring that records INP for header clicks, checkbox clicks, and key presses tells you which interaction is slow on real devices. The runtime-performance lesson covers how to break up long tasks.
Check your understanding · Exercise 12
Pressing the down arrow in the grid re-renders all 480 visible cells. Which fixes target the cause? Select all that apply.
Loading, empty, and error states, export, and phones
These parts are often left for "later" in interviews and in real projects. They are what users see when things are not perfect, which is a lot of the time.
Four states, not one spinner
The naive table shows a spinner whenever a request is in flight. The page flashes on every sort, the layout jumps, and an error looks the same as "no orders". Model the table's data as a small state machine instead, and give each state its own look.
- First load: skeleton rows at the real row height and column widths, so nothing moves when data arrives. No layout shift means a better CLS (Cumulative Layout Shift) score.
- Refreshing (sort, filter, page, refetch): keep the old rows, dim them slightly, show a thin progress bar at the top, set
aria-busy. Only if it takes longer than about 300 ms does the progress bar appear at all, so fast responses don't flicker. - Empty: two different messages. No data at all: "No orders yet" with the action that creates one. No results for this filter: "No orders match these filters" with a Clear filters button. Mixing them up sends users looking for orders that exist.
- Error: on first load, a message with Retry in place of the rows. On refresh, keep the old rows and show an inline banner ("Couldn't load page 3. Retry."), because stale rows are more useful than an empty box.
Export to CSV
In client mode, the browser can build the file: walk the current row model (sorted and filtered), call each visible column's exportValue or accessor, join with commas, wrap the result in a Blob, and download it through a temporary link with URL.createObjectURL. 20,000 rows of 10 columns is a few megabytes and takes well under a second.
- Escape correctly. Wrap fields that contain commas, quotes, or line breaks in double quotes, and double any quote inside them, as RFC 4180 describes.
- Guard against CSV injection. A customer name such as
=HYPERLINK(...)becomes a live formula when someone opens the file in a spreadsheet. OWASP lists cells that start with=,+,-,@, tab, carriage return, or line feed (and full-width forms of the first four) as dangerous, and describes ways to force them to be read as text. Its page warns that Excel can undo the common single-quote prefix and suggests a tab inside the quoted field instead, so test the export in the spreadsheet apps your users open. - Big exports go to the server. For 250,000 orders, the page asks the server to build the file (
POST /api/orders/exports) and shows "We'll notify you when it's ready". The browser never downloads 100 MB of JSON to turn it into 40 MB of CSV.
On a phone
A 375 px wide screen fits about three narrow columns. Managers check orders on their phones, so the table needs a real answer, not a shrunk desktop table.
Decision How does the table work on a narrow screen?
What we pick: Cards for list-like tables such as Orders (people look for one order and open it), horizontal scroll with a pinned first column for comparison tables such as reports. Columns declare a priority and a cardRole (title, subtitle, meta) so the same definitions serve both. All touch targets, including checkboxes and row menus, are at least 24 by 24 CSS pixels, the WCAG 2.2 minimum, and ideally around 44.
Check your understanding · Exercise 13
A user filters Orders to "status: chargeback" and the API returns zero rows. What should the table show? Select all that apply.
Running it in the interview
Sixty minutes is not enough to say everything in this guide. The interviewer wants to see you find the decisions that matter, make them with numbers, and go deep on two or three. Here is one way to spend the hour.
| Minutes | What you do | What you draw or write |
|---|---|---|
| 0 to 6 | Ask the scope questions. Row count first, then columns, features, sharing, devices, accessibility. | A short list of requirements with numbers: 40 / 250,000 / 50M rows, 6 to 80 columns. |
| 6 to 10 | Name the forces: data size, cell count, many state owners, many input methods. | The wireframe: toolbar, header, body, selection bar, pagination. |
| 10 to 20 | The component API: column definitions with accessor and renderer, controlled and uncontrolled state, headless core. | A ColumnDef type and the props of DataTable. |
| 20 to 30 | Client versus server mode, with the crossover math. URL state. The sort sequence with abort and failure. | The data flow and the sort sequence diagram. |
| 30 to 42 | Go deep where the interviewer leans: virtualization and sticky cells, selection across pages, or the grid keyboard model. | The virtual window sketch or the selection state machine. |
| 42 to 52 | Architecture and data model: where each piece of state lives and why. | The architecture boxes and the state table. |
| 52 to 60 | Performance (narrow props, memo), states, phones, export. Then summarize trade-offs. | A short list; no new diagrams. |
Draw the wireframe early, because it gives you and the interviewer shared names ("the selection bar", "the pinned column") for the rest of the hour. Then keep a running list of decisions on the side of the board: client or server, paging or virtualization, table or grid. When the interviewer pushes on one, you can point at it, weigh the options again, and keep going without losing the thread.
Follow-up questions to expect
Think first
"Orders change status every few seconds. How do you keep the table fresh?"
Start with the cheap answer: refetch the current page when the window regains focus and every 30 to 60 seconds while visible, with a Refresh button. The server state library does this with two options.
If the product truly needs live rows, subscribe to changes for the ids on screen only (over SSE or a WebSocket) and patch those rows in the cache. Don't insert new rows into the middle of a sorted page under the user's cursor: show a "12 new orders" pill that applies them when clicked.
Think first
"Product wants rows grouped by customer, with a total per group. What changes?"
The row model becomes a tree: group rows and leaf rows, each with a depth and an expanded flag. In client mode, the headless core can group and sum. In server mode, the server returns groups with totals and loads each group's rows when expanded.
For accessibility, the grid becomes a tree grid: role="treegrid" with aria-level, aria-expanded, and Left and Right arrows to collapse and expand. Virtualization works on the flattened list of visible rows.
Think first
"A customer wants to scroll through 2 million rows without pages. Can we?"
Yes, with cursor pages loaded as the user scrolls, a sliding window of a few pages kept in memory, and row virtualization. Old pages far from the viewport are dropped from the cache.
The scroll height is the hard limit: 2 million rows at 40 px is 80 million pixels, more than browsers allow for one element. Either scale the scrollbar (each pixel of the spacer stands for several rows) or show the scroll position as "rows 1,200,000 to 1,200,050" with a jump-to box. Also ask whether scrolling is really the task; a good filter usually beats two million rows.
Think first
"How would you test this component?"
Unit tests for the pure parts: URL parse and serialize, the selection reducer (ranges, select all matching, exclusions), and the window math. Component tests that drive the keyboard map and check aria-rowindex, aria-sort, and focus after sort.
An automated accessibility scan (for example axe) on each state, plus manual passes with VoiceOver and NVDA, because scans can't judge whether announcements make sense. A performance test that loads 100,000 rows and checks INP for an arrow key and a checkbox.
Check your understanding · Exercise 14
You have 10 minutes left and have not discussed accessibility. What is the best use of them? Select all that apply.
Summary
- Ask for the row count first. It decides client or server mode, paging or scrolling, and whether you need virtualization.
- Columns are accessor plus renderer plus behavior. Sort, filter, and export use the raw value; only the cell draws the formatted one.
- Every piece of state is controlled or uncontrolled, per feature, like an input's
valueanddefaultValue. Query state goes in the URL, layout in user preferences, rows in a server cache. - Server mode needs aborts, a stable tie-breaker sort, page reset on query change, and old rows kept on screen while loading.
- Virtualize rows above a few hundred rendered rows, and columns only for very wide tables. Keep header and pinned column sticky in the same scroller, and keep the focused row mounted.
- Select by id. "Select all matching" is a filter plus exclusions, sent to the server with the count the user saw.
- Use a plain table for read-only data and the grid pattern with roving tabindex for interactive cells. Tell screen readers the real totals with
aria-rowcountandaria-rowindex. - Re-render as few cells as possible: memoized rows, narrow boolean props, stable columns and row objects.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- WAI-ARIA Authoring Practices: Grid pattern Keyboard model, roles, and selection keys such as Shift plus Space.
- WAI-ARIA Authoring Practices: Data grid examples Roving tabindex, sortable headers, and
aria-rowindexon a scrolling grid. - WAI-ARIA Authoring Practices: Sortable table example How
aria-sortand sort buttons work together. - WAI-ARIA 1.2: aria-rowcount Counting rows that aren't in the DOM.
- WCAG 2.2: Understanding Dragging Movements (2.5.7) Why resize and reorder need non-drag alternatives.
- WAI-ARIA Authoring Practices: Window splitter pattern A focusable
separatorwith a value, the model for a keyboard resize handle. - MDN: Element.setPointerCapture() Reliable drags for column resizing.
- MDN: position: sticky Sticky header and pinned columns.
- TanStack Table documentation A headless table core with controlled state and manual (server) modes.
- AG Grid: Community vs. Enterprise Which grid features, such as the server-side row model, need the paid edition.
- Chrome for Developers: Optimize DOM size How Lighthouse judges large DOMs today.
- web.dev: Virtualize large lists with react-window The windowing idea with measurements.
- OWASP: CSV Injection Escaping exported cells that look like formulas.
Concepts behind it
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Server-state librariesTanStack Query, SWR, RTK Query, and Apollo cache server data by key, refetch stale entries in the background, and refresh them after mutations, so components stop hand-writing loading flags, races, and invalidation.