M6: Managing React Lists at Scale
A shared support inbox from hundreds of rows to a million, in five rounds: the window with variable heights, identity under inserts, sorts, filters and updates, pages in a sparse order with a memory budget, and selection, focus, keyboard and screen-reader semantics for rows that do not exist.
The brief and the questions
"A shared inbox for support: every message to the company in one list, newest first, with filters and search, multi-select for bulk actions, keyboard navigation for the power users, and it must feel instant. The busiest queue has about eighty thousand messages; the archive is a million."
- How many items in a view? 80k in the busiest queue; 1M in the archive; a filter typically narrows to hundreds.
- How big is an item; are rows the same height? ~500 bytes; rows vary (one-line subject versus three-line preview with tags).
- How does the list change while open? New messages arrive at the top (tens a minute at peak); status changes on any row; agents delete and archive; sort and filter change often.
- What do users do to rows? Open, select (click, shift-range, select-all), bulk assign/close/tag, keyboard (j/k, Home/End, space to select).
- Accessibility? Required: screen-reader users on the support team.
- Devices? Laptops; a few on tablets.
- What is "instant"? Scroll at 60 fps; a filter under 200 ms; a jump to any position under a second.
- Are items on the client? No: pages from the API; the archive never fits.
- FR: an infinite list with variable-height rows; sort and filter; live inserts and updates; multi-select with ranges and select-all; bulk actions; keyboard navigation; screen-reader support.
- NFR: 1M items in a view; 60 fps scroll; under 35 ms per scroll frame; memory under 50 MB for the list; filter under 200 ms; jump-to-position under 1 s; no scroll jumps on inserts above the viewport; ARIA grid semantics with the true row count.
v1 and v2: the window and variable heights
// v1 → v2: a virtualised inbox with variable heights, keyed by id, reading items from a store
function Inbox({ order, total }: { order: (string | undefined)[]; total: number }) { // order: ids for loaded pages, undefined for holes
const parentRef = useRef<HTMLDivElement>(null)
const virt = useVirtualizer({
count: total, getScrollElement: () => parentRef.current,
estimateSize: () => 56, // the estimate: a one-line row; multi-line rows measure larger and the prefix sums adjust
overscan: 8, getItemKey: i => order[i] ?? `hole-${i}`, // key by id so a row keeps its node and state while in the window
})
return (
<div ref={parentRef} className="scroll" style={{ height: '100%', overflow: 'auto' }}>
<div style={{ height: virt.getTotalSize(), position: 'relative' }}> {/* the spacer: Σ measured + Σ estimated */}
{virt.getVirtualItems().map(v => {
const id = order[v.index]
return (
<div key={v.key} data-index={v.index} ref={virt.measureElement} // ResizeObserver measures after mount
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${v.start}px)` }}>
{id ? <Row id={id} /> : <RowSkeleton />}
</div>
)
})}
</div>
</div>
)
}
const Row = memo(function Row({ id }: { id: string }) {
const item = useStore(s => s.items[id]) // subscribes to this id only: an update to one item re-renders one row
return <article className="row">…</article>
})messages.map(m => <Row key={m.id} />)is correct to about 1k rows. At 10k it is laggy; at 80k it is 480k elements, ~400 MB, a 4 s render, and every scroll frame lays out the whole list. The number is DOM nodes, and it is nodes × cells.
- Render the visible rows plus an overscan inside a spacer whose height is the total; position rows absolutely (or by transform); the visible window is 25 to 35 nodes at any list size. The algorithms course part 1 has the arithmetic; TanStack Virtual, react-window or Virtuoso have the implementation.
- Keys are ids. A row that stays in the window keeps its DOM node and state across scrolls and sorts; index keys would re-render every row into the wrong node.
- Variable heights: estimate, mount, measure with ResizeObserver, keep prefix sums (a plain array under 100k; a Fenwick tree beyond), binary-search scrollTop for the start index, and correct the scroll position as estimates are replaced. The estimate matters: too low and the scrollbar jumps as rows measure larger.
- Frames: a passive scroll listener with no layout reads; the window update is a transition so frames come before rows; the row component under 1 ms (35 rows × 1 ms is the whole frame budget). Measure it in the Performance panel before tuning anything else.
- v2 buys 60 fps at any row count and pays: absolute positioning and a spacer (the browser's find-in-page and native scroll anchoring stop working on rows that do not exist: capability), a measurement scheme for variable heights (complexity), and an estimate to tune (complexity).
Round three: identity under change
Messages arrive at the top while an agent is reading row 400; the row under their pointer jumps; a status change on one row re-renders every row; a sort change loses the expanded preview. The number that broke is changes per minute to a list that was modelled as one array. The fix is two structures.
- Items by id in a store (normalised, M1's v3); the order is an array of ids for the current sort and filter. A row subscribes to its id; the list subscribes to the order. One item's update re-renders one row; an order change re-renders the window.
- Insert above the viewport: add the inserted height to scrollTop in the same frame, so the reader's row does not move. Browsers anchor DOM insertions (
overflow-anchor) but virtualised rows are absolute, so the list does it. Better: do not splice while someone reads; show "12 new" and insert on click or when at the top. - Sort: replace the order; reconciliation moves keyed rows that remain in the window (state kept); FLIP the 35 visible if the move should be legible; never animate the million.
- Filter: a new order; scroll resets (a new query) or stays (a refinement), decided on purpose. A million small ids filter on the client in ~10 ms; items not on the client filter on the server (M1's v2).
- Update, delete: the store routes an update to one row through its id and structural sharing (the React course part 8). A delete removes an id from the order; rows below shift up; a deleted row above the viewport subtracts its height from scrollTop.
- v3 buys a list that changes under the user without moving under them and pays: two structures to keep consistent (complexity), explicit scroll arithmetic on inserts and deletes (complexity), a product decision on every "new items" and "filter changed" case (design time), and the loss of "the list is one array" simplicity.
Round four: a million items as pages in a window
// v3: pages in a window. the order is sparse; fetch-ahead fills it; a page budget bounds memory
const PAGE = 100, AHEAD = 50, BUDGET = 20
const q = useInfiniteQuery({ queryKey: ['inbox', filter, sort], queryFn: ({ pageParam }) => api.inbox.page({ filter, sort, offset: pageParam, limit: PAGE }),
initialPageParam: 0, getNextPageParam: (last, _all, lastOffset) => last.hasMore ? lastOffset + PAGE : undefined, staleTime: 30_000 })
// the sparse order: a Map pageNo → ids; order[i] = pages.get(floor(i / PAGE))?.[i % PAGE]
// fetch-ahead: in an effect on the virtualizer's range
useEffect(() => {
const last = virt.range?.endIndex ?? 0
const needPage = Math.floor((last + AHEAD) / PAGE)
if (!pages.has(needPage) && !inflight.has(needPage)) fetchPage(needPage) // jumps: any page, by offset; dedupe in flight
}, [virt.range?.endIndex])
// eviction: when pages.size > BUDGET, drop the page farthest from the viewport; its indices become holes; the query cache keeps it for gcTime so a return is a cache hit
// new items at the top while reading: do not splice. show "12 new"; on click, prepend and scrollTop += prependedHeight in the same frame (or virtuoso's firstItemIndex)
// deletions: remove the id from its page; rows below shift up; if the deleted row was above the viewport, scrollTop -= its height- The archive is a million items and not on the client. The order must be sparse; the spacer needs a total; a scrollbar drag to the middle needs page 5,000 without pages 1 to 4,999; memory must not grow with scrolling distance. The number is items that cannot fit, and the design is pagination driven by the window.
- Pages of 100 in a sparse order; holes render skeletons at the estimated height so the scrollbar stays honest; the total from the first response sets the spacer.
- Fetch-ahead when the window's end plus 50 rows enters an unloaded page; deduplicate in-flight pages;
useInfiniteQueryholds pages with a staleTime. - Jumps need random access: offsets for stable sorts, or a seekable key (jump to March → a cursor from 1 March, with the scrollbar mapped to dates). Cursor-only pagination cannot jump (M1).
- A page budget: keep the ~20 pages nearest the viewport; evict the farthest; evicted indices become holes; the query cache's gcTime makes a return a cache hit. 20 × 100 × 500 B is 1 MB held for a million-item list.
- Reverse lists (a chat thread) load older pages above and prepend with the anchoring arithmetic every time; Virtuoso's
firstItemIndexdoes it for you.
- v4 buys a million items in 1 MB and sub-second jumps and pays: a sparse order with skeletons (complexity), fetch-ahead tuning per link speed (complexity), an API that supports offsets or seekable keys (a contract), an eviction policy (complexity), and the brief latency of a skeleton on fast scrolls (latency).
Round five: selection, focus and the screen reader
Select-all on 80k, shift-click across unloaded pages, j/k past the window's edge, and a screen reader that announces "35 rows". The number that broke is interactions that assume rows exist; the fix models each as data about ids and indices, and tells assistive technology the true shape.
- Selection model:
{ mode: 'some', ids }or{ mode: 'all', except }, an anchor id, and spans (start, end) in the current order for ranges over unloaded rows; the server evaluates spans against the same sort (M1's v4 predicate). A sort change clears spans or converts loaded ones to ids, with a message. The count is arithmetic: ids.size, or total − except.size, or span lengths. - Focus: one active id in state; roving tabindex on the rendered rows; the real DOM focus on the scroll container with
aria-activedescendant, so focus survives the active row unmounting. The most common virtualised-list bug is focus dropping to body when the row scrolls away. - Keyboard is order arithmetic: Home → 0, End → total − 1 (fetch, then focus), PageDown → +window, j/k → ±1; type-ahead is a server search, not a row scan.
- ARIA grid:
role="grid"witharia-rowcount={total}; rows carryaria-rowindex; a live region announces selection counts. The screen reader says "row 4,513 of 1,000,000, selected" and navigates by row though 35 exist. Test with VoiceOver or NVDA; the APG grid pattern has keyboard rules no linter checks.
- v5 buys bulk, ranges, keyboard and screen-reader use at a million rows and pays: a selection model the API shares (complexity, a contract), focus plumbing (complexity), fetch-then-focus on jumps (latency), and the grid pattern implemented once in the list component and tested with a screen reader (complexity).
- Stop: every number in the brief is met. Grouping by date, drag-and-drop reordering and column resizing are features on top of the same three structures.
The whole board, and the exercise
| Round | The number | The break | The design | Paid in |
|---|---|---|---|---|
| v1 | Hundreds of rows | 80k rows = 480k nodes; 4 s render; layout per scroll | A plain keyed map | Nothing; correct to ~1k |
| v2 | 80k rows, variable heights | DOM nodes × cells | A window: spacer, absolute rows, overscan, id keys; estimate-measure-prefix-sum for heights; transitions for frames | No native find or anchoring on absent rows; a measurement scheme; an estimate to tune |
| v3 | Tens of changes a minute | Inserts jump the reader; one update re-renders all; sorts lose state | Store by id + order of ids; scroll arithmetic on insert and delete; "12 new" instead of splicing; FLIP the visible | Two structures; explicit arithmetic; product decisions per change type |
| v4 | 1M items off the client | Sparse data; random-access jumps; memory with distance | Pages in a sparse order; skeleton holes; fetch-ahead; offsets or seekable keys; a page budget with eviction | An API contract; tuning; skeleton latency on fast scrolls |
| v5 | Select-all, ranges, keyboard, SR | Interactions assume rows exist | Selection by ids, except and spans; roving focus with activedescendant; order-arithmetic keyboard; ARIA grid with true counts | A shared selection contract; focus plumbing; fetch-then-focus; the grid pattern |
- The DOM is a cache of the visible; the list lives in a store, an order and a selection model.
- Identity is permanent, position is a view; keys are ids, and every change to the list is an operation on the order with scroll arithmetic where it touches the viewport.
- A million items is a sparse order with a page budget; the API must support jumps or the UI cannot offer them.
- Selection, focus and accessibility are models about ids and indices, implemented once in the list component, tested with a screen reader.