Part 6 · 6 chapters · ~45 min

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.

38

The brief and the questions

the brief

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

the questions, and the answers
  1. How many items in a view? 80k in the busiest queue; 1M in the archive; a filter typically narrows to hundreds.
  2. How big is an item; are rows the same height? ~500 bytes; rows vary (one-line subject versus three-line preview with tags).
  3. 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.
  4. 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).
  5. Accessibility? Required: screen-reader users on the support team.
  6. Devices? Laptops; a few on tablets.
  7. What is "instant"? Scroll at 60 fps; a filter under 200 ms; a jump to any position under a second.
  8. Are items on the client? No: pages from the API; the archive never fits.
the requirements, with numbers
  1. 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.
  2. 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.
the three things a list at scale is
A window (which rows exist), an identity (what a key means when the order changes under you), and a selection model (what "all" means when most rows do not exist). Everything in this part is one of the three.
39

v1 and v2: the window and variable heights

code
// 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>
})
v1: the plain map, and where it stops
  1. 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.
v2: the window
  1. 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.
  2. 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.
  3. 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.
  4. 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.
the sentence
  1. 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).
THE WINDOW
a list of a million is thirty DOM nodes and a scroll height
swipe the figure sideways, or tap expand for full screen
1/6
naive
A naive list of 100k rows: 100k DOM nodes × 6 cells = 600k elements, ~400 MB, a 4 s initial render, and every scroll frame pays for a layout the size of the list. 10k is already laggy; 1k is the practical ceiling for a plain map.
40

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.

v3: a store and an order
  1. 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.
  2. 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.
  3. 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.
  4. 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).
  5. 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.
the sentence
  1. 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.
IDENTITY, ORDER AND CHANGE
what a key means when the list is sorted, filtered, inserted into and updated underneath you
swipe the figure sideways, or tap expand for full screen
1/6
store and order
The store: items by id (a Map or a normalised object: M1's v3). The order: an array of ids for the current sort and filter. A row reads its item by id from the store; the list reads ids from the order. Updating one item re-renders one row; changing the order re-renders the window, not the items.
41

Round four: a million items as pages in a window

code
// 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 break
  1. 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.
v4
  1. 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.
  2. Fetch-ahead when the window's end plus 50 rows enters an unloaded page; deduplicate in-flight pages; useInfiniteQuery holds pages with a staleTime.
  3. 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).
  4. 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.
  5. Reverse lists (a chat thread) load older pages above and prepend with the anchoring arithmetic every time; Virtuoso's firstItemIndex does it for you.
the sentence
  1. 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).
V3: INFINITE SCROLL AS PAGES IN A WINDOW
fetch ahead, keep what is near, drop what is far, and know the total
swipe the figure sideways, or tap expand for full screen
1/6
pages + holes
Pages of 100 items by cursor (M1's v2) or by offset when the order is stable. The order array has holes: ids for loaded pages, placeholders elsewhere. The spacer uses the total from the first response (or an estimate updated as pages arrive). Rows for unloaded indices render a skeleton.
42

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.

v5
  1. 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.
  2. 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.
  3. 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.
  4. ARIA grid: role="grid" with aria-rowcount={total}; rows carry aria-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.
the sentence, and the stop
  1. 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).
  2. 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.
V4: SELECTION, FOCUS AND KEYBOARD AT A MILLION
a selection model by id and predicate, a roving focus, and what the DOM cannot hold
swipe the figure sideways, or tap expand for full screen
1/6
the model
Selection model: { mode: "some", ids: Set } or { mode: "all", except: Set } plus an anchor id for ranges. A row's checkbox reads isSelected(id) from the model; selecting a row that is not loaded is impossible for "some" and implicit for "all". The count is ids.size or total − except.size.
43

The whole board, and the exercise

RoundThe numberThe breakThe designPaid in
v1Hundreds of rows80k rows = 480k nodes; 4 s render; layout per scrollA plain keyed mapNothing; correct to ~1k
v280k rows, variable heightsDOM nodes × cellsA window: spacer, absolute rows, overscan, id keys; estimate-measure-prefix-sum for heights; transitions for framesNo native find or anchoring on absent rows; a measurement scheme; an estimate to tune
v3Tens of changes a minuteInserts jump the reader; one update re-renders all; sorts lose stateStore by id + order of ids; scroll arithmetic on insert and delete; "12 new" instead of splicing; FLIP the visibleTwo structures; explicit arithmetic; product decisions per change type
v41M items off the clientSparse data; random-access jumps; memory with distancePages in a sparse order; skeleton holes; fetch-ahead; offsets or seekable keys; a page budget with evictionAn API contract; tuning; skeleton latency on fast scrolls
v5Select-all, ranges, keyboard, SRInteractions assume rows existSelection by ids, except and spans; roving focus with activedescendant; order-arithmetic keyboard; ARIA grid with true countsA shared selection contract; focus plumbing; fetch-then-focus; the grid pattern
what the sequence teaches
  1. The DOM is a cache of the visible; the list lives in a store, an order and a selection model.
  2. 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.
  3. A million items is a sparse order with a page budget; the API must support jumps or the UI cannot offer them.
  4. Selection, focus and accessibility are models about ids and indices, implemented once in the list component, tested with a screen reader.
the exercise
Take a list in a product you own. Count its rows at the largest real account; check its keys; insert an item above the viewport while scrolled; press Tab into it and arrow past the window's edge; turn on a screen reader and ask how many rows. Each failure maps to one round above; fix the earliest.