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)
// Step 1: a pure function with no React, no DOM, no refs. Given the current
// scrollTop, item height, container height, total count, and an overscan
// buffer, return which slice of the array to render and where to position it.
// Easy to unit-test, easy to reason about. The hook in the next accordion is
// only 15 lines once this is extracted.
function getVisibleRange(scrollTop, itemHeight, containerHeight, totalCount, overscan) {
const safeScroll = Math.max(0, scrollTop);
const safeItem = Math.max(1, itemHeight);
const safeOver = Math.max(0, overscan | 0);
const firstVisible = Math.floor(safeScroll / safeItem);
const visibleCount = Math.ceil(containerHeight / safeItem);
const startIndex = Math.max(0, firstVisible - safeOver);
const endIndex = Math.min(totalCount - 1, firstVisible + visibleCount + safeOver);
return { startIndex, endIndex, offsetY: startIndex * safeItem };
}
// Concrete walkthrough. Container is 600px tall, each row is 40px, list has
// 10,000 rows. We ask for an overscan of 5 rows above and below the viewport.
const itemHeight = 40;
const containerHeight = 600;
const totalCount = 10000;
const overscan = 5;
// At the top of the list.
console.log('scroll=0 ->', getVisibleRange(0, itemHeight, containerHeight, totalCount, overscan));
// Halfway through.
console.log('scroll=4000 ->', getVisibleRange(4000, itemHeight, containerHeight, totalCount, overscan));
// Near the bottom: endIndex clamps to totalCount-1.
console.log('scroll=399000 ->', getVisibleRange(399000, itemHeight, containerHeight, totalCount, overscan));
// Negative scroll (rubber-band on iOS) clamps to 0.
console.log('scroll=-200 ->', getVisibleRange(-200, itemHeight, containerHeight, totalCount, overscan));
// Sanity: rendering 25 rows out of 10,000.
const sample = getVisibleRange(4000, itemHeight, containerHeight, totalCount, overscan);
console.log('rendered window size:', sample.endIndex - sample.startIndex + 1, 'of', totalCount);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.
// Step 2: wrap the pure function in a hook that listens for scroll events on
// a ref'd container and returns { startIndex, endIndex, totalHeight, offsetY }
// plus the ref to attach. The caller renders only the visible slice and an
// outer spacer that gives the scrollbar the right size.
const { useState, useEffect, useRef, useCallback } = (typeof React !== 'undefined' ? React : {
useState: (init) => {
let v = typeof init === 'function' ? init() : init;
return [v, (n) => { v = typeof n === 'function' ? n(v) : n; return v; }];
},
useEffect: (fn) => { const c = fn(); return typeof c === 'function' ? c : undefined; },
useRef: (init) => ({ current: init }),
useCallback: (f) => f,
});
function getVisibleRange(scrollTop, itemHeight, containerHeight, totalCount, overscan) {
const safeScroll = Math.max(0, scrollTop);
const safeItem = Math.max(1, itemHeight);
const safeOver = Math.max(0, overscan | 0);
const firstVisible = Math.floor(safeScroll / safeItem);
const visibleCount = Math.ceil(containerHeight / safeItem);
const startIndex = Math.max(0, firstVisible - safeOver);
const endIndex = Math.min(totalCount - 1, firstVisible + visibleCount + safeOver);
return { startIndex, endIndex, offsetY: startIndex * safeItem };
}
function useWindowing(opts) {
const { itemHeight, containerHeight, totalCount, overscan } = opts;
const containerRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
const onScroll = useCallback(() => {
const node = containerRef.current;
if (node) setScrollTop(node.scrollTop);
}, []);
useEffect(() => {
const node = containerRef.current;
if (!node || !node.addEventListener) return;
node.addEventListener('scroll', onScroll, { passive: true });
return () => node.removeEventListener('scroll', onScroll);
}, [onScroll]);
const { startIndex, endIndex, offsetY } = getVisibleRange(
scrollTop, itemHeight, containerHeight, totalCount, overscan || 5
);
return {
containerRef,
startIndex,
endIndex,
offsetY,
totalHeight: totalCount * itemHeight,
};
}
// Drive the hook with a fake scroll container so the playground sees output.
const listeners = [];
const fakeContainer = {
scrollTop: 0,
addEventListener: (_evt, fn) => listeners.push(fn),
removeEventListener: () => {},
};
const api = useWindowing({ itemHeight: 40, containerHeight: 600, totalCount: 10000 });
api.containerRef.current = fakeContainer;
console.log('initial window ->', { start: api.startIndex, end: api.endIndex, totalHeight: api.totalHeight });
// Simulate a scroll. In a real app the browser fires scroll; here we trip it manually.
fakeContainer.scrollTop = 4000;
listeners.forEach((fn) => fn());
const after = useWindowing({ itemHeight: 40, containerHeight: 600, totalCount: 10000 });
after.containerRef.current = fakeContainer;
console.log('after scroll=4000 -> total spacer height stays at', after.totalHeight, 'so scrollbar is correct');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.
// Step 3: drop the fixed-itemHeight assumption. Real lists are messy: some
// rows wrap to two lines, dividers are 8px, expandable rows mutate after
// click. Maintain a prefix-sum cache of measured offsets so getItemOffset(i)
// is O(1) and the visible-range computation is a binary search.
function makeOffsetCache(estimatedHeight) {
const measured = new Map(); // index -> measured height
let prefix = [0]; // offsets[i] = sum of heights[0..i-1]
let lastDirtyIndex = 0;
return {
setMeasured(index, height) {
const prev = measured.get(index);
if (prev === height) return;
measured.set(index, height);
if (index < lastDirtyIndex) lastDirtyIndex = index;
},
getOffset(index, totalCount) {
// Lazy rebuild from the first dirty index forward, up to what we need.
while (lastDirtyIndex <= index && lastDirtyIndex < totalCount) {
const h = measured.get(lastDirtyIndex);
prefix[lastDirtyIndex + 1] = prefix[lastDirtyIndex] + (h != null ? h : estimatedHeight);
lastDirtyIndex++;
}
return prefix[index] != null ? prefix[index] : index * estimatedHeight;
},
getTotalHeight(totalCount) {
return this.getOffset(totalCount, totalCount);
},
findIndexAtOffset(target, totalCount) {
// Ensure prefix is built up to where we need to search, then binary search.
this.getOffset(totalCount, totalCount);
let lo = 0, hi = totalCount;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (prefix[mid + 1] <= target) lo = mid + 1;
else hi = mid;
}
return lo;
},
};
}
// Walk-through. 1,000 items, estimated 40px. A handful of measured rows are taller.
const cache = makeOffsetCache(40);
const totalCount = 1000;
// Before measurement: every offset uses the estimate.
console.log('estimated offset of #50:', cache.getOffset(50, totalCount), '(50 * 40 = 2000)');
console.log('estimated total height:', cache.getTotalHeight(totalCount), '(1000 * 40 = 40000)');
// Measure a few rows that turned out taller than the estimate.
cache.setMeasured(10, 80); // double-line
cache.setMeasured(11, 80);
cache.setMeasured(20, 120); // image preview
console.log('offset of #50 after measurement:', cache.getOffset(50, totalCount));
console.log('total height after measurement: ', cache.getTotalHeight(totalCount));
console.log('index at scroll offset 1000: ', cache.findIndexAtOffset(1000, totalCount));
console.log('index at scroll offset 5000: ', cache.findIndexAtOffset(5000, totalCount));
// Re-measuring an earlier row invalidates everything after it. The next
// getOffset call rebuilds lazily.
cache.setMeasured(10, 90);
console.log('offset of #50 after re-measure: ', cache.getOffset(50, totalCount));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.
