TechLead
πŸ“œ
Intermediate
11 min read

Virtual 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-window for simple cases, react-virtualized for complex ones
  • Implement dynamic height calculation for variable content
  • Combine with infinite scrolling for large datasets
  • Use will-change: transform CSS 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} />

Continue Learning