cf.completefrontend
My progress
SYSTEM DESIGN · #21

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.

QuestionAnswer we assumeWhat 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.
Wireframe of the Orders table. At the top, a toolbar with a search box holding acme, a filter chip status failed, and a Columns button. Below it, a sticky header row with a select-all checkbox and the columns Order, Customer, and Total, sorted descending. Four rows follow: orders 4521, 4519, 4502, and 4498, each with a checkbox; 4519 and 4502 are selected. The Order column is pinned on the left. Under the rows, a selection bar says 2 selected with Refund and Assign. At the bottom, pagination with pages 1, 2, 3 and 26.Orderssearch: acmestatus: failedColumns[ ]OrderCustomerTotal, desc4521Acme Ltd1,299.00x4519Bolt Co980.00x4502Cora GmbH455.104498Dune Inc120.002 selected: Refund, AssignPrev 1 2 3 ... 26 Nexttoolbarsticky headerpinned id columnselected rowsselection barpagination
Name the parts before designing them: toolbar, sticky header, pinned column, row checkboxes, selection bar, pagination. Each one owns a piece of state that a later chapter gives a home.

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.

Three tables compared. Team members: 40 rows, about 16 KB, so client mode, render all rows, exact count. Orders: 250,000 rows, about 100 MB of JSON, so server mode, pages of 50, exact count. Audit log: 50 million rows, about 20 GB, so server mode, cursor pages with virtualization, and an estimated count.Team members40 rows, about 16 KBclient moderender allexact countOrders250,000 rows, 100 MBserver modepages of 50exact countAudit log50M rows, about 20 GBserver modecursor + virtualestimated countabout 400 bytes of JSON per order row
The same component, three answers. Row count times row size decides where sorting runs; how people read the data decides between pages and scrolling.

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.

ForceWhat it decidesChapter
Data sizeClient or server for sort, filter, page; paging or infinite scrollWhere the data lives
Cells multiplyVirtualization of rows and columns; memoized cellsRendering 100,000 rows
Many owners of stateControlled and uncontrolled props; URL state; persistenceThe component API, Where the data lives
Many input methodsGrid role, roving focus, row and column indexesKeyboard and screen readers
30 teamsColumn definitions, renderers, headless coreThe component API

Think first

A team says: "Our table only has 300 rows, so none of this matters to us." Is that true?

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 example order.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.
ts
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} /> },
];
One column definition type serves every team. The cell renderer is the escape hatch for any look.

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.

Layers of the component API. At the top, the Orders page passes columns, rows, and sorting state into the DataTable. Inside the DataTable, the headless table core handles sorting, filtering, selection, and sizes, and produces a row model. The view layer draws the markup, ARIA, keyboard handling, and virtualization from that row model, and calls the teams' cell renderers. Events such as onSortingChange go back up to the page.Page: Orderscolumns, rows, sorting, callbacksDataTable (design system)Table coresort, filter, select, sizesView layermarkup, ARIA, keys, windowrow modelcell renderersevents
The core decides what to show; the view decides how. Teams only touch the top (column definitions and props) and the cell renderers, so the core can change without breaking them.

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.

tsx
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
    />
  );
}
The Orders page controls sort, filters, and page through the URL; the table owns focus and widths.
Two ways to own sorting state. Left, uncontrolled: the Team page passes only columns and rows, and the DataTable keeps its sorting in its own state. Right, controlled: the URL with sort=-total is read by the Orders page, which owns sorting and passes it to the DataTable; when the user clicks a header, the DataTable calls onSortingChange, and the page pushes the new sort into the URL.uncontrolled: TeamTeamPagecolumns, rowsDataTableown sorting stateno callbacks neededcontrolled: OrdersURL ?sort=-totalOrdersPageowns sortingDataTablesortingonChangepushBack workslinks work
The same component in both modes. If 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?

Write everything ourselves

Our own sort, filter, page, selection, column sizing logic, rendering, and virtualization.

Full control of every line.

Pros

  • No dependency, exact fit to our design system.
  • Smallest possible code for the simple cases.

Cons

  • Months of work to reach the edge cases a mature library already handles: multi-sort, stable sort, pinned columns, resize math, selection across pages.
  • Every bug is ours to find.

A headless core (for example TanStack Table) plus our own rendering

The library manages table state and row models: sorting, filtering, pagination, selection, column sizing, ordering, and pinning.

It renders nothing. We write the markup, styles, ARIA, and virtualization (for example with TanStack Virtual).

Pros

  • The logic is tested by many apps; the look and the accessibility are fully ours.
  • Supports controlled state and a manual (server-side) mode for each feature.
  • Far smaller than a full grid, and recent versions are tree-shakable, so a table bundles mostly the features it uses.

Cons

  • We still build virtualization, keyboard navigation, and ARIA ourselves.
  • Its types and concepts take a day or two for teams to learn.

A batteries-included grid (for example AG Grid)

A complete grid component with its own rendering, virtualization, editing, keyboard model, and themes.

Advanced features such as row grouping and a server-side row model are in the paid edition, per the vendor's current licensing.

Pros

  • Very fast to ship a powerful grid, including spreadsheet-like editing and very large datasets.
  • Row and column virtualization built in.

Cons

  • Heavy: the full package is hundreds of kilobytes of compressed JavaScript (its modules can trim that), paid even by the 40-row table.
  • Hard to make look and behave exactly like the rest of our design system.
  • License cost and terms for the advanced features.

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?

Client mode: download all, work locally

One request returns every row.

Sort with Array.prototype.sort and an Intl.Collator, filter with plain predicates, slice for pages.

Pros

  • Instant interactions with no loading states.
  • Works offline once loaded.
  • Simple API: one endpoint, no query language.

Cons

  • Download, parse, and memory grow with the row count.
  • The data can go stale; a second user's changes don't show until a full refetch.

Server mode: send the query, get one page

Each change of sort, filter, or page sends a request with those parameters.

The server answers with the page of rows and a total (or a next cursor).

Pros

  • Works for any row count, including millions.
  • Always reasonably fresh: every query hits the server.

Cons

  • Every interaction waits for the network (100 to 500 ms).
  • Needs request cancellation, loading states, and error handling for every click.
  • The server must index the sortable and filterable columns.

Both, picked per table (with an automatic threshold)

The table supports both modes behind the same props.

A page sets mode explicitly, or asks the server for the total first and switches to client mode below a threshold such as 5,000 rows or 2 MB of JSON.

Pros

  • Each table gets the right trade-off.
  • Teams write one column definition for both modes.

Cons

  • Two code paths to test.
  • Client compare functions and server sort order must agree, or switching modes changes the order users see.

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.

Rows
Bytes per row
Network
Sorts and filters per visit
Client mode versus server mode for 5,000 rows of 400 bytes on a office network with 20 sorts and filters. Client mode: first screen after 60 ms, total waiting 120 ms. Server mode: first screen after 250 ms, total waiting 5,250 ms. Verdict: client mode.client mode: download all, then work locallyfirst screen 60 ms, total 120 msserver mode: one request per actionfirst screen 250 ms, total 5,250 msraw 2 MBwire 0.25 MBpick: clientassumes 8x compression, 10 ms parse per MB, 250 ms per request
Client mode: one 60 ms load, then each action takes about 3 ms instead of 250 ms.

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.

Sequence for sorting in server mode, between the Header, the Page with the URL, the Query cache, and the API. The header reports sort by total descending to the page. The page changes the URL, so the query key changes and the old request is aborted. The cache sends GET with sort=-total to the API, and meanwhile tells the header to keep the old rows dimmed. The API answers 200 with 50 rows and the total, and the table re-renders with new rows. On the failure path, the API answers 500; the page reverts the URL and the sort, and the table keeps the old rows with an inline error.HeaderPage/URLQuery cacheAPIsort: -totalnew key, abort oldGET ?sort=-totalkeep old rows200: 50 rows, totalre-render new rowsfailure path500 errorrevert, show error
The arrow on the header changes at once; the rows change when data arrives. On failure everything goes back together (URL, sort arrow, rows), so the screen never shows a sort it didn't apply. The AbortController stops an older, slower request from winning.
  1. 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.
  2. The page writes ?sort=-total&page=1 to the URL. The query key changes, so the server state library starts a request and aborts the previous one with AbortController.
  3. 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.
  4. The response arrives and the rows are replaced. Focus stays on the header the user clicked.
  5. 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 page to a sane range. A hand-edited ?sort=password must 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?

Paging only

Never render more than one page (25 to 100 rows).

Long lists become many pages.

Pros

  • Simple. Native table layout, Ctrl+F find, and printing all just work.
  • Screen readers see every row of the page.

Cons

  • Users who scan or compare across hundreds of rows must click a lot.
  • Doesn't help a 20,000-row client-mode table that wants one long scroll.

CSS content-visibility: auto on row groups

All rows are in the DOM, but the browser skips layout and paint for groups that are off screen.

Needs contain-intrinsic-size so the skipped groups keep a height.

Pros

  • One line of CSS, keeps find in page and full DOM semantics.
  • Good for a few thousand rows.

Cons

  • Every row still exists as DOM nodes and React components: memory and React render time still grow with the row count.
  • Doesn't help at 100,000 rows.

Row (and column) virtualization

A virtualizer reads the scroll position, computes which rows overlap the viewport, and renders only those plus an overscan of 5 to 10 rows.

Spacers or absolute positions give the full scroll height. Columns can be windowed the same way.

Pros

  • DOM size stays constant: about 30 rows whether there are 300 or 300,000.
  • Smooth scrolling and fast first render at any size.

Cons

  • Browser find in page can't see rows that aren't rendered.
  • Assistive technology needs row counts and indexes (later chapter).
  • Variable row heights need measuring, which can cause small scroll jumps.

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.

ts
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-height row windowing. Variable heights replace the division with a lookup in measured offsets.

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.

Row virtualization for 100,000 rows of 40 px. On the left, the full scroll height: a top spacer of 119,760 px, then 32 rendered rows, then a bottom spacer of 3,878,960 px. On the right, the viewport, 800 px tall: a sticky header row at top 0, a pinned order column at left 0, and the visible rows 3,000 to 3,019 in the body. Overscan of 6 rows above and 6 below is rendered but not visible.top spacer119,760 px32 rowsbottom spacer3,878,960 pxviewport, 800 pxheader: sticky, top: 0left: 0rows 3,000 to 3,019visibleoverscan: 6 above, 6 below, off screen
The DOM always holds about 32 rows; spacers fake the other 99,968. Header and pinned column stick inside the same scroller with 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: sticky on the th and td elements, which every current browser engine supports. Sticky on thead only arrived in Chrome 91, and sticky on tr is 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: hidden between 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 left is 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.

  1. Use Pointer Events. One set of pointerdown, pointermove, and pointerup handlers covers mouse, touch, and pen. Add touch-action: none on the handle so a finger drag resizes instead of scrolling the page.
  2. Capture the pointer. On pointerdown, call handle.setPointerCapture(event.pointerId). Every later move and the final pointerup go to the handle, even when the pointer leaves it or the window.
  3. 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.
  4. Don't re-render cells while dragging. Write the width into a CSS custom property on the table (for example --col-customer: 240px) inside a requestAnimationFrame. Cells already use width: var(--col-customer), so the browser updates the layout without React touching a single cell. Save the final width to state once, on pointerup.
Sequence for resizing the Customer column, between the Pointer, the Resize handle, the CSS variable, and the Layout store. Pointerdown on the handle; the handle captures the pointer. A pointermove of 80 px to the right sets the CSS variable to 240 px. A pointermove of 150 px to the left is clamped at the 60 px minimum. On pointerup, the handle saves the widths to the layout store, which writes to localStorage and later to the server.PointerResize handleCSS variableLayout storepointerdowncapture pointermove +80 px--col: 240pxmove -150 pxclamp at 60pxpointerupsave widthslocal, then server
React state changes once, at 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?

HTML drag and drop (draggable headers)

Set draggable on header cells and handle dragstart, dragover, and drop.

Pros

  • Built into the browser; little code.

Cons

  • Limited control over the drag preview and the drop indicator.
  • Touch support has been uneven across browsers, and it does nothing for keyboard users.

Pointer-driven drag on the header

Pointer Events with pointer capture, like resizing. While dragging, draw a ghost header and a drop line between columns.

On pointerup, compute the new index from the drop line and update the column order once.

Pros

  • Same code for mouse, touch, and pen.
  • Full control of the preview, auto-scroll near the edges, and pinned-column rules.

Cons

  • More code than native drag and drop.
  • Still needs a non-drag alternative for keyboard and single-pointer users.

Non-drag controls: a header menu and a Columns panel

Each header menu has "Move left" and "Move right".

A Columns panel lists every column with a checkbox (show or hide) and move up and down buttons.

Pros

  • Works with keyboard, screen readers, switch devices, and single clicks.
  • The panel is also where people find hidden columns again.

Cons

  • Slower than dragging for mouse users moving a column far.

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".

ts
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 } }
Two shapes of selection. The second never downloads 1,284 ids; the server applies the filter.
  • 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.
State machine for row selection. From none, ticking a row goes to some ids, where the header checkbox shows a dash. Ticking the header goes to all on page, where the header is checked. Choosing select all 1,284 goes to all matching. Unticking a row there goes to all but some: the filter minus a few ids. Any change of filter or search clears the selection back to none; a new sort or page keeps it.nonesome idsheader: dashall on pageheader: checkedall matching1,284 rowsall but somefilter minus idstick a rowheaderselect all 1,284untickfilter or search changes: back to none, from any state
Two of the five states don't hold ids at all: they hold a filter. That's how one click can select 1,284 rows, or a million, without loading them. A filter or search change always returns to none.

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.

  1. Optimistic save. Show the new value at once and send PATCH /api/orders/ord_42 in the background, with a small "saving" mark on the cell.
  2. Version check. Send the version you edited (an If-Match header with the row's ETag, or a version field). If another agent changed the order meanwhile, the server answers 412 Precondition Failed (or 409 Conflict), and you show both values instead of silently overwriting theirs.
  3. 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.
  4. 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.
  5. 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.
Sequence for an inline edit, between the Cell, the Query cache, and the API. The cell reports the edit, status refunded. The cache shows the new value at once. It sends PATCH with If-Match version 7. The API answers 200 with version 8, and the cell shows saved. On the conflict path, the PATCH with If-Match 7 gets 412 because another agent changed the order; the cache rolls the value back and the cell keeps the typed draft with an error.CellQuery cacheAPIedit: status refundedshow new value nowPATCH, If-Match: 7200, version 8savedconflict pathPATCH, If-Match: 7412: changed by Anaroll back, keep draft
The new value shows before the server answers. The 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"?

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?

Plain table, Tab through controls

Semantic table, thead, th, td.

Every checkbox, link, and menu button in a cell is a Tab stop.

Pros

  • No custom key handling; screen reader table commands work as users expect.
  • Hard to get wrong.

Cons

  • 50 rows with a checkbox, a link, and a menu is 150 Tab stops before reaching the pagination.
  • No cell-by-cell movement for editing.

Grid with roving tabindex

One cell has tabindex="0", all others tabindex="-1".

Arrow keys move real DOM focus to the next cell and swap the tabindex values.

Pros

  • One Tab stop in and out of the grid.
  • Real focus means the browser scrolls the cell into view and screen readers follow it reliably.

Cons

  • We must implement every key: arrows, Home, End, Page Up, Page Down, Ctrl plus Home and End.
  • With virtualization, the focused cell must stay mounted or focus is lost.

Grid with aria-activedescendant

DOM focus stays on the grid container.

aria-activedescendant holds the id of the "current" cell, which is styled as focused.

Pros

  • Focus never moves, so it can't be lost when rows re-render.
  • Simple for editable single-widget cells.

Cons

  • The referenced cell must still be in the DOM.
  • We must scroll it into view ourselves.
  • Screen reader support for active descendants inside grids has historically been less consistent than real focus.

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

KeyWhat it does in our grid
TabMoves into the grid (to the last focused cell) or out of it. Never cell to cell.
Arrow keysMove one cell up, down, left, or right. Stop at the edges (no wrapping).
Home / EndFirst or last cell in the row.
Ctrl + Home / Ctrl + EndFirst cell of the first row, or last cell of the last row (loading it if needed).
Page Up / Page DownMove by one screen of rows (about 20).
SpaceToggle selection of the focused row and make it the anchor.
Shift + SpaceSelect the focused row, the shortcut the APG grid pattern suggests for a checkbox column.
Shift + Up / Shift + DownMove one row and extend the selection from the anchor.
Enter or F2On a header: sort. On an editable cell: start editing. On a cell with one button or link: activate it.
EscapeCancel 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.

DOM rows: 6 of 250,001. Selected: 0. Anchor: none.
SelectOrderCustomerTotal (high first)
586962Bolt Co9853.28
594881Cora GmbH9853.24
602800Dune Inc9853.20
610719Elm and Sons9853.17
618638Fable SA9853.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.

Component architecture. The Orders page holds useUrlTableState, which reads the query from the URL, and the Query cache, which keeps pages by key and calls the API client, which talks to the BFF and orders API. Below, the DataTable from the design system: the Table core holds state and builds the row model; the Header handles sort, resize, and menus and saves to the Layout store; the Virtualizer picks rows and sets aria-rowindex; memoized Rows and cells call the renderers; the Focus controller handles roving tabindex and keys. Also inside: the selection bar, pagination, status layer, and live region.userOrders pageuseUrlTableState?sort, ?statusQuery cachepages by keyAPI clientBFF, APIDataTableTable corestate, row modelHeadersort, resize, menuLayout storewidths, orderVirtualizeraria-rowindexRows, cellsmemo, renderersFocus, keysroving tabindexqueryrows, totalalso: selection bar, pagination, status layer, live region
The page owns the query and the fetching; the table owns drawing, focus, and layout. The only things that cross the line are rows, a total, state values, and change events.
PartLives inResponsibility
useUrlTableStatePageReads 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)PageFetches 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 clientSharedBuilds the request URL, attaches auth, parses the response and the error format.
DataTable shellDesign systemTakes columns, rows, state, and callbacks. Chooses table or grid mode. Lays out toolbar, header, body, footer.
Table core (headless)Design systemHolds 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.
HeaderDesign systemSort buttons with aria-sort, resize handles, header menus, drag to reorder.
Virtualizer and bodyDesign systemWorks out which rows and columns to render, writes spacers, keeps the focused row mounted, sets aria-rowindex.
Row and cellsDesign system plus team renderersMemoized row component; calls each column's renderer with the value and the row.
Focus controllerDesign systemRoving tabindex, the keyboard map, entering and leaving edit mode.
Selection bar, pagination, status layerDesign systemBulk actions with a count; page controls; loading, empty, and error states.
Layout storeDesign systemColumn order, widths, hidden, pinned; localStorage now, server later.
Live regionDesign systemPolite 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.

Data flow for the Orders table. The URL with status failed and sort by total becomes a query key in the Query cache. On a miss, the cache asks the API client, which sends GET to the BFF or CDN, which queries the Orders API. The Orders API returns rows; the BFF returns about 20 KB of JSON, a few KB after compression, to the API client, which stores the page in the cache. The cache gives 50 rows and the total to the DataTable. The DataTable sends events to the page callbacks, which write sort and filters to the URL, and send edits as PATCH through the API client.URL?status=failedOrders APIindexed sortQuery cachekey: the queryAPI clientfetch, abortBFF, CDNauth, BrotliDataTablerow modelPage callbacksonSortingChangequery keyGETrows20 KB JSONstore50 rows, totaleventsPATCHsort, filter: URLedits: PATCH
Every read starts at the URL and ends in cells; every write goes up as an event. Sorting changes the URL, not the rows, and an edit is a PATCH plus an optimistic cache update.
  1. The URL ?status=failed&sort=-total&page=2 is parsed into a query object.
  2. 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=50 through the CDN or backend-for-frontend to the orders service.
  3. About 20 KB of JSON comes back (a few KB after compression): 50 rows, the total, and the server's applied sort and filters.
  4. The cache stores the page. Going back to page 1 now shows instantly from cache while a background refetch checks it.
  5. The table core builds the row model. The virtualizer picks rows, and each memoized row renders its cells through the team's renderers.
  6. A click on a header or a filter flows up as onSortingChange or onFiltersChange, which writes the URL, which starts the loop again. An inline edit flows up as onCellEdit, which the page turns into a PATCH and 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.

StateHomeWhy there
Sort, filters, search, pageURLShareable, survives reload, Back undoes it.
Page sizeUser preferencesPersonal; a shared link shouldn't force 200 rows on a phone.
Rows and totalServer state cache, keyed by queryA copy of server data; refetched, deduplicated, and updated after edits.
Column order, widths, hidden, pinnedlocalStorage plus user preferences on the serverPersonal, long-lived, follows the user to other devices.
SelectionPage component state (controlled) or table stateLives as long as the current filters and search; cleared when they change.
Focused cell, editing cell, draft valueTable internal stateNobody else needs it, and it changes on every key press.
Pending editsServer state cache (optimistic) plus a per-cell statusSo every view of the order shows the new value, and errors can roll back.
Resize in progressA CSS variable, not React stateChanges 60 times a second; only the final width is committed.
ts
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 };
The client-side types. Row data stays as the server sends it; the table adds nothing to it.
Four homes for the table's state. The URL holds sort, filters, search, and page, because they are shareable. The server cache holds rows, the total, and pending edits, because they are copies of server data. User preferences hold column widths, order, hidden columns, and page size, because they are personal. Table state holds focus, the edit draft, and selection, because they are private and short-lived.URL: shareablesortfilterssearchpageserver cache: copiesrowstotalpending editsuser prefs: personalwidthsorderhiddenpage sizetable state: privatefocusdraftselection
Ask two questions of each piece of state: who should see it (a colleague, only me, nobody), and how long should it live. The answers pick the home.

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

http
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 }]
}
One list endpoint. Sort and filter fields are checked against an allow-list on the server too.
  • Raw values, not formatted ones. totalCents and an ISO date, not "€1,299.00" or "yesterday". The client formats with Intl.NumberFormat and Intl.DateTimeFormat in 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

http
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
An edit with a version check, a bulk action by filter, and an export job.

The component's props and events

Prop or eventTypeNotes
columnsarray of column definitionsAccessor, renderer, sort and filter settings, widths, pinning.
rows, getRowIdarray, function returning a stringIds keep selection and focus stable across sorts and refetches.
mode"client" or "server"Server mode shows rows as given and only reports state changes.
rowCountnumber or nullTotal in server mode; drives pagination and aria-rowcount.
sorting / defaultSorting / onSortingChangesort keysControlled, uncontrolled, and the change callback. Same trio for filters, pagination, selection, and column layout.
loading, error, emptyStateboolean, error, nodeDrive the status layer; the table keeps old rows while loading.
onCellEdit(row id, column id, value) returning a promiseThe table shows saving, success, or error from the promise.
onRowActivate(row id)Enter on a row, or a click on its primary link.
layoutKeystringTurns on saved layouts for this user.
density"compact", "normal", "relaxed"Sets the fixed row height that virtualization relies on.
virtualizeboolean or "auto"Auto turns it on above about 500 rows.
captionstringThe 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 selectedIds set to every row: when it changes, every row sees a new prop. Pass isSelected to each row, so only rows whose answer changed re-render.
  • Pass focus narrowly. Each row gets focusedColumnId only if it's the focused row, and null otherwise. 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.
tsx
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} />
  );
})}
Only the rows whose props change will re-render. The comparison is shallow and cheap.

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.Collator and reuse it. Calling a.localeCompare(b, locale) inside the compare function can build locale data on every call; a shared new 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 (or startTransition), 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: fixed with 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 scrollTop once per frame, compute the range, and write. Reading offsetHeight after 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.

State machine for the table's data. Loading shows skeleton rows. A 200 goes to rows. Rows with zero results go to empty, which has two messages. A failed first load goes to error with Retry, and Retry goes back to loading. From rows, a sort or page change goes to refreshing, which keeps the old rows dimmed; a 200 returns to rows, and a failure goes to stale with a banner, keeping the old rows and offering Retry.loadingskeleton rowsrowsdata shownemptytwo messageserrorRetryrefreshingold rows dimmedstale, bannerold rows, Retry2000 rowsfailsRetrysort, page200fails
A failure while refreshing is not the same as a failure on first load: old rows stay on screen with a banner. Zero rows is a success state with its own message, not an error.
  • 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?

Horizontal scroll with a sticky first column

Same table; it scrolls sideways inside its container.

The order number stays pinned on the left.

Pros

  • No extra code; every column is still there.
  • Good for tables people compare across columns.

Cons

  • Users see 2 or 3 columns at a time and must remember headers.
  • Sideways scrolling on a phone is easy to trigger by accident.

Priority columns

Each column has a priority. Below set widths, low-priority columns hide.

A row tap opens the full record.

Pros

  • Still looks like a table; sorting stays obvious.

Cons

  • Teams must rank columns.
  • Hidden data needs a second step to reach.

Cards under a breakpoint

Below about 640 px, each row renders as a card: title field, 2 or 3 key fields, status.

Sort and filters move into a bottom sheet; selection uses a long press or a Select mode.

Pros

  • Readable at any width; big touch targets.
  • Works with the same column definitions plus a small card layout hint.

Cons

  • A second layout to test.
  • Not good for comparing many columns.

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.

Two phone layouts. On the left, the desktop table squeezed onto a phone: only the order number, part of the customer, and part of the total fit, and the rest needs sideways scrolling. On the right, below 640 px the rows become cards: each card shows the order number as a title, the customer and total on a second line, and a status badge.squeezed4521Acme1,24521Acme1,24521Acme1,24521Acme1,24521Acme1,2below 640 pxcardsOrder 4521Acme Ltd, 1,299.00failedOrder 4519Bolt Co, 980.00paidOrder 4502Cora GmbH, 455.10failed2 or 3 columns fittitle, key fields, status
The squeezed table hides most columns behind sideways scrolling. Cards show the 3 or 4 fields that matter for finding an order, from the same column definitions with a priority and a card role.

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.

MinutesWhat you doWhat you draw or write
0 to 6Ask 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 10Name the forces: data size, cell count, many state owners, many input methods.The wireframe: toolbar, header, body, selection bar, pagination.
10 to 20The component API: column definitions with accessor and renderer, controlled and uncontrolled state, headless core.A ColumnDef type and the props of DataTable.
20 to 30Client 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 42Go 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 52Architecture and data model: where each piece of state lives and why.The architecture boxes and the state table.
52 to 60Performance (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?"

Think first

"Product wants rows grouped by customer, with a total per group. What changes?"

Think first

"A customer wants to scroll through 2 million rows without pages. Can we?"

Think first

"How would you test this component?"

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 value and defaultValue. 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-rowcount and aria-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.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next