the windowing idea: O(viewport), not O(data)
A plain items.map() mounts one DOM node per item — 1,000 rows → 1,000 nodes,
and the browser re-layouts all of them on every scroll. Virtualization
renders only the slice inside the scroll viewport. The scrollbar is faked with a full-height
spacer (height = total × itemHeight); the visible rows are absolutely positioned
inside it. As you scroll, onScroll → setScrollTop recomputes the slice and React
mounts/unmounts just the rows entering or leaving the window.
| variable | formula | example (scrollTop = 1200px) |
|---|---|---|
startIndex |
floor(scrollTop / itemHeight) |
floor(1200 / 40) = 30 |
visibleCount |
ceil(viewportHeight / itemHeight) + overscan |
ceil(400 / 40) + 2 = 12 |
endIndex |
min(startIndex + visibleCount, total) |
min(30 + 12, 10000) = 42 |
offsetY (spacer) |
startIndex × itemHeight (= the inner div's full height sells the scrollbar) |
30 × 40 = 1200px |
row top |
i × itemHeight (absolute position inside spacer) |
row #35 → top = 1400px |
1 · the virtual list you write (edit me)
2 · Babel compiles JSX → element tree
<VirtualList/> becomes React.createElement(VirtualList).
The loop only pushes visibleCount elements — the other 988 rows never become
DOM nodes. On scroll, setScrollTop changes startIndex and React
reconciles the new short list.
// (hit "compile & render" to see Babel's output)
3 · live React (windowing, proven)
There are 1,000 rows of data, but only ~12 are ever in the DOM. Scroll the box (or let the gold-check scroll it for you): the DOM nodes badge stays ~12 while Rendered reflects the active window — that gap is the whole point.
DOM nodes (live): — / 1000 · gold: count~12 → assert #1 → scroll → assert #201 → re-count~12
intent → pattern
| intent | pattern | why |
|---|---|---|
| render 10k rows without lag | slice to the visible window; absolute-position each row | ~12 DOM nodes regardless of total |
| compute the visible range | start=floor(scrollTop/h); count=ceil(vp/h)+buffer |
O(1) arithmetic — no scan over data |
| fake the full scroll height | inner spacer div: height = total × itemHeight |
scrollbar looks right; browser owns momentum/inertia |
| paint a row in its slot | position:absolute; top: i × itemHeight |
no layout shift; each row lands at its exact offset |
| hide blank flash on fast scroll | overscan: render ±2 rows beyond the viewport edges | covers the gap while React commits the new slice |
| variable row heights | measure + cache each row's height; recompute cumulative offsets | needs a measurement pass — reach for a library here |