List Virtualization: Render Large Lists Efficiently

Hard

List virtualization is essential for rendering large datasets efficiently. Master this technique for handling thousands of items.

Quick Decision Guide

Quick Implementation Guide:

react-window: Lightweight library for list virtualization. Use FixedSizeList for equal-height items, VariableSizeList for dynamic heights.

How it works: Renders only visible items + small buffer. Calculates scroll position to show correct items.

Performance gain: Rendering 10,000 items → ~20 DOM nodes instead of 10,000. 100x performance improvement.

Best Practice: Use for lists with 100+ items. Provide accurate item heights for smooth scrolling. Add overscan for better UX.

Basic Virtualization

Fixed Size List

import { FixedSizeList } from 'react-window';

function VirtualList({ data }) {
  const Row = ({ index, style }) => (
    <div style={style} className="list-item">
      <h3>{data[index].title}</h3>
      <p>{data[index].description}</p>
    </div>
  );

  return (
    <FixedSizeList
      height={600}        // Container height
      itemCount={data.length}
      itemSize={80}       // Each item is 80px tall
      width="100%"
      overscanCount={5}   // Render 5 extra items for smooth scrolling
    >
      {Row}
    </FixedSizeList>
  );
}

How It Works

Viewport (600px tall):
  Visible: Items 0-7 (8 items × 75px)
  Buffer (overscan): Items 8-12 (5 extra)
  
Total rendered: 13 items (not 10,000!)

User scrolls:
  New visible: Items 10-17
  New buffer: Items 8-9, 18-22
  Items 0-7 are unmounted, 10-17 mounted

Performance Comparison

Traditional (10,000 items):
  - DOM nodes: 10,000
  - Initial render: 5.2s
  - Memory: 250MB
  - Scroll FPS: 15fps

Virtualized (10,000 items):
  - DOM nodes: ~15
  - Initial render: 0.05s (100x faster!)
  - Memory: 3MB
  - Scroll FPS: 60fps

Variable Size Lists

Dynamic Heights

import { VariableSizeList } from 'react-window';

function DynamicList({ data }) {
  const listRef = useRef(null);
  
  // Cache item heights
  const getItemSize = (index) => {
    // Short items: 60px, long items: 120px
    return data[index].description.length > 100 ? 120 : 60;
  };
  
  const Row = ({ index, style }) => (
    <div style={style} className="list-item">
      <h3>{data[index].title}</h3>
      <p>{data[index].description}</p>
    </div>
  );

  return (
    <VariableSizeList
      ref={listRef}
      height={600}
      itemCount={data.length}
      itemSize={getItemSize}  // Function, not fixed number
      width="100%"
      estimatedItemSize={80}  // Average height for calculations
      overscanCount={5}
    >
      {Row}
    </VariableSizeList>
  );
}

Reset Cache on Data Change

function DynamicList({ data }) {
  const listRef = useRef(null);
  
  // Reset cached heights when data changes
  useEffect(() => {
    listRef.current?.resetAfterIndex(0);
  }, [data]);
  
  // ... rest of component
}

Measuring Actual Heights

const Row = ({ index, style, data }) => {
  const rowRef = useRef(null);
  
  useEffect(() => {
    if (rowRef.current) {
      const height = rowRef.current.getBoundingClientRect().height;
      // Update cache if actual height differs
      if (height !== style.height) {
        data.setSize(index, height);
      }
    }
  }, [index, data, style.height]);
  
  return (
    <div ref={rowRef} style={style}>
      {/* Content with unknown height */}
    </div>
  );
};

Grid Virtualization

Fixed Size Grid

import { FixedSizeGrid } from 'react-window';

function VirtualGrid({ data, columnCount }) {
  const Cell = ({ columnIndex, rowIndex, style }) => {
    const index = rowIndex * columnCount + columnIndex;
    const item = data[index];
    
    return (
      <div style={style} className="grid-cell">
        <img src={item.image} alt={item.title} />
        <p>{item.title}</p>
      </div>
    );
  };

  return (
    <FixedSizeGrid
      columnCount={columnCount}
      columnWidth={200}
      height={600}
      rowCount={Math.ceil(data.length / columnCount)}
      rowHeight={250}
      width={800}
    >
      {Cell}
    </FixedSizeGrid>
  );
}

Responsive Grid

function ResponsiveGrid({ data }) {
  const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
  
  const columnCount = Math.floor(dimensions.width / 200);
  
  useEffect(() => {
    const handleResize = () => {
      setDimensions({
        width: window.innerWidth,
        height: window.innerHeight - 100
      });
    };
    
    window.addEventListener('resize', handleResize);
    handleResize();
    
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  
  return (
    <FixedSizeGrid
      columnCount={columnCount}
      columnWidth={200}
      height={dimensions.height}
      rowCount={Math.ceil(data.length / columnCount)}
      rowHeight={250}
      width={dimensions.width}
    >
      {Cell}
    </FixedSizeGrid>
  );
}

Infinite Scroll with Virtualization

Load More on Scroll

import { FixedSizeList } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';

function InfiniteList({ loadMore, hasNextPage, items }) {
  const itemCount = hasNextPage ? items.length + 1 : items.length;
  
  const isItemLoaded = (index) => !hasNextPage || index < items.length;
  
  const loadMoreItems = () => {
    if (!hasNextPage) return Promise.resolve();
    return loadMore();
  };

  const Row = ({ index, style }) => {
    if (!isItemLoaded(index)) {
      return <div style={style}>Loading...</div>;
    }
    
    return (
      <div style={style}>
        {items[index].name}
      </div>
    );
  };

  return (
    <InfiniteLoader
      isItemLoaded={isItemLoaded}
      itemCount={itemCount}
      loadMoreItems={loadMoreItems}
      threshold={15}  // Load more when 15 items from end
    >
      {({ onItemsRendered, ref }) => (
        <FixedSizeList
          height={600}
          itemCount={itemCount}
          itemSize={50}
          onItemsRendered={onItemsRendered}
          ref={ref}
          width="100%"
        >
          {Row}
        </FixedSizeList>
      )}
    </InfiniteLoader>
  );
}

Usage with API

function App() {
  const [items, setItems] = useState([]);
  const [hasNextPage, setHasNextPage] = useState(true);
  const [page, setPage] = useState(0);
  
  const loadMore = async () => {
    const response = await fetch(`/api/items?page=${page}`);
    const newItems = await response.json();
    
    setItems(prev => [...prev, ...newItems]);
    setPage(prev => prev + 1);
    setHasNextPage(newItems.length > 0);
  };
  
  useEffect(() => {
    loadMore();
  }, []);
  
  return (
    <InfiniteList
      items={items}
      loadMore={loadMore}
      hasNextPage={hasNextPage}
    />
  );
}

Advanced Patterns

Scrolling to Item

function ScrollableList({ data, initialScrollIndex }) {
  const listRef = useRef(null);
  
  const scrollToItem = (index) => {
    listRef.current?.scrollToItem(index, 'center');
  };
  
  return (
    <>
      <button onClick={() => scrollToItem(500)}>
        Jump to item 500
      </button>
      
      <FixedSizeList
        ref={listRef}
        height={600}
        itemCount={data.length}
        itemSize={50}
        initialScrollOffset={initialScrollIndex * 50}
        width="100%"
      >
        {Row}
      </FixedSizeList>
    </>
  );
}

Item Data & Context

function List({ data, onItemClick }) {
  // Pass data to all rows without re-rendering
  const itemData = useMemo(() => ({
    items: data,
    onClick: onItemClick
  }), [data, onItemClick]);
  
  const Row = memo(({ index, style, data }) => {
    const { items, onClick } = data;
    const item = items[index];
    
    return (
      <div 
        style={style} 
        onClick={() => onClick(item)}
      >
        {item.name}
      </div>
    );
  });
  
  return (
    <FixedSizeList
      height={600}
      itemCount={data.length}
      itemSize={50}
      itemData={itemData}  // Passed to Row component
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

Custom Scrollbar

function CustomScrollList({ data }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={data.length}
      itemSize={50}
      width="100%"
      style={{
        scrollbarWidth: 'thin',
        scrollbarColor: '#888 #f1f1f1'
      }}
      className="custom-scrollbar"
    >
      {Row}
    </FixedSizeList>
  );
}

// CSS

css

.custom-scrollbar::-webkit-scrollbar {

width: 8px;

}

.custom-scrollbar::-webkit-scrollbar-track {

background: #f1f1f1;

}

.custom-scrollbar::-webkit-scrollbar-thumb {

background: #888;

border-radius: 4px;

}

```

Best Practices

When to Virtualize

✅ Virtualize When:

List has 100+ items
Items are similar in structure
Smooth scrolling is important
Mobile performance matters

❌ Don't Virtualize When:

List has < 50 items
Items vary drastically in height
List is rarely scrolled
Accessibility is critical (screen readers)

Performance Tips

// 1. Memoize row components
const Row = memo(({ index, style, data }) => {
  return <div style={style}>{data[index].name}</div>;
});

// 2. Use stable keys
<FixedSizeList
  itemKey={(index, data) => data[index].id}  // Use stable ID
>
  {Row}
</FixedSizeList>

// 3. Optimize renders
const itemData = useMemo(
  () => ({ items, onItemClick }),
  [items, onItemClick]
);

// 4. Proper overscan
<FixedSizeList
  overscanCount={5}  // Balance between smoothness and performance
>
  {Row}
</FixedSizeList>

Accessibility

// Add ARIA attributes
function AccessibleList({ data }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={data.length}
      itemSize={50}
      width="100%"
      role="list"
      aria-label="Virtualized list"
    >
      {({ index, style }) => (
        <div 
          style={style} 
          role="listitem"
          aria-setsize={data.length}
          aria-posinset={index + 1}
        >
          {data[index].name}
        </div>
      )}
    </FixedSizeList>
  );
}

Testing

// Test with large datasets
const testData = Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  name: `Item ${i}`,
  description: `Description for item ${i}`
}));

// Measure performance
console.time('render');
render(<VirtualList data={testData} />);
console.timeEnd('render');
// Expected: < 50ms