Community JavaScript Snippet
Windowing a 10k Row List Without react-window
We had a 10k row list freezing scroll. react-window worked but was overkill for our shape. Here is the 60 line virtualizer we shipped instead.
Windowing a 10k Row List Without react-window
We had a 10k row list freezing scroll. react-window worked but was overkill for our shape. Here is the 60 line virtualizer we shipped instead.
By @norapetrov
February 10, 2026
·
Updated May 20, 2026
765 views
15
4.3 (10)
I keep the math out of the hook because every windowing bug I have ever shipped lived in this function, and a pure function is the only place I can test those bugs without spinning up a renderer. Three clamps matter. First, Math.max(0, scrollTop) because iOS rubber-band scrolling reports negative values on overscroll. Second, Math.max(1, itemHeight) because a zero-height row would divide by zero on the first pass. Third, Math.min(totalCount - 1, ...) so the end index never points past the array. The overscan buffer is what hides the white-flash when you scroll fast: render a few rows above and below the viewport so the next ones are already in the DOM.
Three pieces are doing all the work. The ref captures the scroll container so the hook can subscribe to its scroll events. The state holds the latest scrollTop, which I update with the cheapest possible callback so React batches it under a single render even on fast scroll. And the returned totalHeight is the trick that makes the scrollbar feel right: even though only 25 rows are mounted, the outer spacer is sized for all 10,000, so dragging the scroll thumb behaves exactly like a non-virtualized list. { passive: true } on the listener is non-negotiable: omitting it lets the scroll handler block compositing, which is the bug that motivated this rewrite for us in the first place.
Variable heights break the multiplication trick that made fixed-height windowing easy, so I lean on a prefix-sum cache. Three things make it tractable. The cache stores per-index measurements as they arrive from a ResizeObserver (or a useLayoutEffect callback ref); unmeasured rows fall back to the estimate. The lastDirtyIndex cursor turns re-measurement of row 10 into an invalidation of everything past it without rebuilding the whole prefix array eagerly. And findIndexAtOffset is a binary search over the prefix, which is what replaces the Math.floor(scrollTop / itemHeight) line from accordion 1. The estimate matters for first paint: pick something close to your median row height or the scrollbar will jump as measurements arrive.
