π
Intermediate
11 min readVirtual Scrolling & Windowing
Rendering large lists efficiently with virtual scrolling
Understanding Virtual Scrolling
Virtual scrolling (windowing) renders only the visible portion of a large list, dramatically improving performance when dealing with thousands or millions of items.
The Problem with Large Lists
// β Bad: Rendering 10,000 items at once
function LargeList({ items }) {
return (
{items.map(item => (
{item.name}
))}
);
}
// Problems:
// - Creates 10,000 DOM nodes
// - Long initial render time
// - Heavy memory usage
// - Slow scrolling performance
Basic Virtual Scrolling Implementation
function VirtualList({ items, itemHeight = 50, containerHeight = 600 }) {
const [scrollTop, setScrollTop] = useState(0);
// Calculate visible range
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight);
// Get only visible items
const visibleItems = items.slice(startIndex, endIndex + 1);
// Calculate total height and offset
const totalHeight = items.length * itemHeight;
const offsetY = startIndex * itemHeight;
const handleScroll = (e) => {
setScrollTop(e.target.scrollTop);
};
return (
{visibleItems.map((item, index) => (
{item.name}
))}
);
}
Using react-window
// Install: npm install react-window
import { FixedSizeList } from 'react-window';
// Simple fixed-height list
function MyList({ items }) {
const Row = ({ index, style }) => (
{items[index].name}
);
return (
{Row}
);
}
// Variable height list
import { VariableSizeList } from 'react-window';
function VariableList({ items }) {
const getItemSize = (index) => {
// Return dynamic height based on content
return items[index].content.length > 100 ? 100 : 50;
};
const Row = ({ index, style }) => (
{items[index].title}
{items[index].content}
);
return (
{Row}
);
}
// Grid layout
import { FixedSizeGrid } from 'react-window';
function MyGrid({ items }) {
const Cell = ({ columnIndex, rowIndex, style }) => {
const index = rowIndex * 5 + columnIndex;
return (
{items[index]?.name}
);
};
return (
{Cell}
);
}
Advanced: Dynamic Height with Auto-Sizing
import { VariableSizeList } from 'react-window';
import AutoSizer from 'react-virtualized-auto-sizer';
function DynamicList({ items }) {
const listRef = useRef();
const rowHeights = useRef({});
// Measure row height after render
const setRowHeight = (index, size) => {
listRef.current.resetAfterIndex(0);
rowHeights.current = { ...rowHeights.current, [index]: size };
};
const getRowHeight = (index) => {
return rowHeights.current[index] || 80; // Default height
};
const Row = ({ index, style }) => {
const rowRef = useRef();
useEffect(() => {
if (rowRef.current) {
setRowHeight(index, rowRef.current.clientHeight);
}
}, [index]);
return (
{items[index].title}
{items[index].content}
);
};
return (
{({ height, width }) => (
{Row}
)}
);
}
Infinite Scrolling with Virtual List
import { FixedSizeList } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
function InfiniteList() {
const [items, setItems] = useState([]);
const [hasMore, setHasMore] = useState(true);
// Check if item is loaded
const isItemLoaded = (index) => !hasMore || index < items.length;
// Load more items
const loadMoreItems = async (startIndex, stopIndex) => {
const newItems = await fetchItems(startIndex, stopIndex);
setItems(prev => [...prev, ...newItems]);
if (newItems.length === 0) {
setHasMore(false);
}
};
const Row = ({ index, style }) => {
const item = items[index];
if (!isItemLoaded(index)) {
return Loading...;
}
return (
{item.name}
);
};
return (
{({ onItemsRendered, ref }) => (
{Row}
)}
);
}
Virtual Scrolling with Intersection Observer
// Lightweight custom implementation
function useVirtualScroll(items, options = {}) {
const {
itemHeight = 50,
overscan = 3, // Extra items to render above/below
containerHeight = 600
} = options;
const [scrollTop, setScrollTop] = useState(0);
const [isScrolling, setIsScrolling] = useState(false);
const scrollTimeoutRef = useRef();
const handleScroll = (e) => {
setScrollTop(e.target.scrollTop);
setIsScrolling(true);
clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = setTimeout(() => {
setIsScrolling(false);
}, 150);
};
// Calculate visible range with overscan
const startIndex = Math.max(
0,
Math.floor(scrollTop / itemHeight) - overscan
);
const endIndex = Math.min(
items.length - 1,
Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan
);
const visibleItems = items.slice(startIndex, endIndex + 1);
const offsetY = startIndex * itemHeight;
const totalHeight = items.length * itemHeight;
return {
visibleItems,
offsetY,
totalHeight,
startIndex,
handleScroll,
isScrolling
};
}
// Usage
function VirtualList({ items }) {
const {
visibleItems,
offsetY,
totalHeight,
startIndex,
handleScroll,
isScrolling
} = useVirtualScroll(items, {
itemHeight: 50,
containerHeight: 600,
overscan: 5
});
return (
{visibleItems.map((item, index) => (
{isScrolling ? (
Loading...
) : (
)}
))}
);
}
Performance Monitoring
// Measure virtual list performance
function usePerformanceMonitor() {
const renderCount = useRef(0);
const startTime = useRef(performance.now());
useEffect(() => {
renderCount.current++;
const elapsed = performance.now() - startTime.current;
if (elapsed > 1000) {
console.log(`FPS: ${(renderCount.current / elapsed * 1000).toFixed(2)}`);
renderCount.current = 0;
startTime.current = performance.now();
}
});
}
// Compare performance
function ComparisonTest() {
const items = Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Item ${i}`
}));
// Regular list (slow)
const RegularList = () => (
{items.map(item => (
{item.name}
))}
);
// Virtual list (fast)
const VirtualList = () => (
{({ index, style }) => (
{items[index].name}
)}
);
}
Best Practices
- Use virtual scrolling for lists with 100+ items
- Add overscan (extra rendered items) for smoother scrolling
- Use
react-windowfor simple cases,react-virtualizedfor complex ones - Implement dynamic height calculation for variable content
- Combine with infinite scrolling for large datasets
- Use
will-change: transformCSS for smoother scrolling - Avoid heavy computations in row components
- Memoize row components with
React.memo - Test performance with realistic data volumes
- Consider grid virtualization for table-like layouts
Building a Virtual Scroller from Scratch
Understanding how virtual scrolling works under the hood helps you tune libraries and handle edge cases. The core idea: listen to scroll events, calculate which items are in the visible window, render only those items with absolute positioning, and set the container height to the total list height so the scrollbar feels natural.
import { useState, useRef, useCallback } from 'react';
function VirtualList({ items, itemHeight, containerHeight }) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);
const totalHeight = items.length * itemHeight;
const visibleCount = Math.ceil(containerHeight / itemHeight);
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(startIndex + visibleCount + 2, items.length); // +2 overscan
const visibleItems = items.slice(startIndex, endIndex);
const onScroll = useCallback((e) => {
setScrollTop(e.currentTarget.scrollTop);
}, []);
return (
<div
ref={containerRef}
style={{ height: containerHeight, overflowY: 'auto', position: 'relative' }}
onScroll={onScroll}
>
{/* Spacer that gives the scrollbar its full range */}
<div style={{ height: totalHeight, position: 'relative' }}>
{visibleItems.map((item, i) => (
<div
key={item.id}
style={{
position: 'absolute',
top: (startIndex + i) * itemHeight,
height: itemHeight,
width: '100%',
}}
>
{item.name}
</div>
))}
</div>
</div>
);
}
// Usage: renders only ~20 rows even with 10,000 items
<VirtualList items={tenThousandItems} itemHeight={50} containerHeight={600} />