frontend

Virtual Scrolling in React

January 9, 2026

Virtual Scrolling in React: A Complete Guide

Introduction

Imagine you're building a photo gallery app that needs to display thousands of images. If you render all 5,000 images at once, your browser would grind to a halt. The DOM would be bloated, memory usage would skyrocket, and your users would experience lag with every interaction.

This is where virtual scrolling (also called windowing) comes to the rescue. It's an optimization technique that only renders the items currently visible in the viewport, plus a small buffer zone. The rest? They exist in data, but not in the DOM.

The Problem: DOM Bloat

When you render a large list traditionally, every single item exists in the DOM simultaneously:

// ❌ This creates 5000 DOM nodes!
{
  items.map((item) => <ListItem key={item.id} data={item} />);
}

Performance issues with large lists:

  • Rendering: Initial render takes a long time
  • Memory: Each DOM node consumes memory
  • Updates: React has to diff thousands of components
  • Interactivity: Scrolling, clicking, and typing become sluggish
  • Paint/Layout: Browser struggles to calculate positions

The Solution: Virtual Scrolling

Virtual scrolling is based on a simple insight: users can only see a small portion of a long list at any given time. Why render what they can't see?

Core Concepts

  1. Viewport: The visible area where items are displayed
  2. Virtual Height: The total height the list would have if all items were rendered
  3. Visible Range: The subset of items currently in view
  4. Buffer (Overscan): Extra items rendered above/below the viewport to prevent flickering
  5. Offset: The vertical position where visible items should be rendered

How It Works

┌─────────────────┐
│  Buffer Items   │ ← Rendered but above viewport
├─────────────────┤
│                 │ ← Viewport starts here
│  Visible Items  │ ← What user actually sees
│                 │ ← Viewport ends here
├─────────────────┤
│  Buffer Items   │ ← Rendered but below viewport
└─────────────────┘
     ↓
[Unrendered Items] ← These don't exist in DOM

Implementation Breakdown

Let's break down the implementation step by step:

1. Setup and Configuration

const ITEM_HEIGHT = 80; // Fixed height per item
const VIEWPORT_HEIGHT = 600; // Container height
const BUFFER = 3; // Items to render beyond viewport

These constants define the behavior of your virtual list:

  • ITEM_HEIGHT: Each item must have a fixed, known height
  • VIEWPORT_HEIGHT: Determines how many items are visible
  • BUFFER: Prevents white flashes during fast scrolling

2. Track Scroll Position

const [scrollTop, setScrollTop] = useState(0);

const handleScroll = (e) => {
  setScrollTop(e.target.scrollTop);
};

The scrollTop value tells us how far down the user has scrolled. This is the foundation for calculating which items to render.

3. Calculate Total Height

const totalHeight = ITEM_HEIGHT * data?.length;

This creates a "phantom" height that makes the scrollbar behave correctly. Even though we're only rendering ~10 items, the scrollbar acts like all 5,000 items are there.

4. Calculate Visible Range

// First visible item (with buffer above)
const startIndex = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER);

// Last visible item (with buffer below)
const endIndex = Math.min(
  data?.length - 1,
  Math.ceil((scrollTop + VIEWPORT_HEIGHT) / ITEM_HEIGHT) + BUFFER
);

Math breakdown:

  • scrollTop / ITEM_HEIGHT = how many items have been scrolled past
  • Math.floor() = converts to whole item index
  • - BUFFER = includes extra items above for smooth scrolling
  • scrollTop + VIEWPORT_HEIGHT = scroll position + viewport = bottom of visible area
  • + BUFFER = includes extra items below

5. Slice Visible Items

const visibleItems = data?.slice(startIndex, endIndex + 1);

Extract only the items that need to be rendered. If you're viewing items 50-60, only those 10 items (plus buffer) are extracted.

6. Calculate Offset

const offsetY = startIndex * ITEM_HEIGHT;

This positions the visible items at the correct vertical location. If you're viewing item 100, it needs to appear 8,000px down (100 × 80px).

7. Render with Positioning

<div style={{ height: `${VIEWPORT_HEIGHT}px` }} onScroll={handleScroll}>
  {/* Phantom spacer for scrollbar */}
  <div style={{ height: `${totalHeight}px`, position: "relative" }}>
    {/* Positioned visible items */}
    <div style={{ transform: `translateY(${offsetY}px)` }}>
      {visibleItems?.map((item) => (
        <div key={item.id} style={{ height: `${ITEM_HEIGHT}px` }}>
          {/* Item content */}
        </div>
      ))}
    </div>
  </div>
</div>

Structure explanation:

  • Outer div: Scrollable container with fixed height
  • Middle div: Creates the total virtual height
  • Inner div: Positions visible items using CSS transform

Complete Implementation

import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { useRef, useState } from "react";

const VirtualList = () => {
  const ITEM_HEIGHT = 80;
  const VIEWPORT_HEIGHT = 600;
  const BUFFER = 3;
  const [scrollTop, setScrollTop] = useState(0);

  const ref = useRef(null);
  const { data } = useQuery({
    queryKey: ["fetch-virtual"],
    queryFn: () => {
      return axios
        .get("https://jsonplaceholder.typicode.com/photos")
        .then((r) => r.data);
    },
  });

  // Per item height * total items = total height of the list
  const totalHeight = ITEM_HEIGHT * data?.length;

  // Start index is the first item visible in the viewport
  const startIndex = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER);

  // End index is the last item visible in the viewport
  const endIndex = Math.min(
    data?.length - 1,
    Math.ceil((scrollTop + VIEWPORT_HEIGHT) / ITEM_HEIGHT) + BUFFER
  );

  // Visible items are the items currently in the viewport
  const visibleItems = data?.slice(startIndex, endIndex + 1);

  // Offset Y is the vertical position of the first visible item
  const offsetY = startIndex * ITEM_HEIGHT;

  const handleScroll = (e) => {
    setScrollTop(e.target.scrollTop);
  };

  return (
    <div
      ref={ref}
      onScroll={handleScroll}
      className="overflow-auto"
      style={{
        height: `${VIEWPORT_HEIGHT}px`,
      }}
    >
      <div
        style={{
          height: `${totalHeight}px`,
          position: "relative",
        }}
      >
        <div
          style={{
            transform: `translateY(${offsetY}px)`,
          }}
        >
          {visibleItems?.map((value) => {
            return (
              <div
                key={value.id}
                className="border h-20 m-1 rounded p-2 w-full"
                style={{
                  height: `${ITEM_HEIGHT}px`,
                }}
              >
                <p>{value.id}</p>
                {value.title}
                {value.url}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

Performance Comparison

| Metric | Traditional List | Virtual List | | ----------------------- | ---------------- | -------------- | | Initial Render | 5000 components | ~10 components | | DOM Nodes | 5000+ nodes | ~10 nodes | | Memory Usage | High | Low | | Scroll Performance | Laggy | Smooth | | Time to Interactive | Slow | Fast |

Key Advantages

  1. Constant Performance: Rendering 100 or 100,000 items has nearly the same performance
  2. Lower Memory: Only visible items consume memory
  3. Faster Initial Load: Users see content immediately
  4. Smooth Scrolling: Less work for the browser during scroll events
  5. Better UX: No lag or stuttering, even on low-end devices

Limitations and Considerations

1. Fixed Heights Required

Items must have a known, fixed height. Dynamic heights require more complex calculations:

// ❌ Won't work well with virtual scrolling
<div style={{ height: 'auto' }}>Content of varying height</div>

// ✅ Works perfectly
<div style={{ height: '80px' }}>Fixed height content</div>

2. Buffer Size Matters

  • Too small: White flashes during fast scrolling
  • Too large: Rendering too many items, defeating the purpose
  • Sweet spot: Usually 3-5 items is optimal

3. Scroll Position Jumps

When items are added/removed above the viewport, scroll position can jump. You need to track and adjust scroll position:

// Adjust scroll when items are prepended
if (itemsAddedAbove) {
  containerRef.current.scrollTop += itemsAddedAbove * ITEM_HEIGHT;
}

Advanced Optimizations

1. Dynamic Heights

For variable-height items, maintain a height cache:

const heightCache = useRef({});

const getItemHeight = (index) => {
  return heightCache.current[index] || ESTIMATED_HEIGHT;
};

2. Horizontal Scrolling

Apply the same concept horizontally:

const visibleColumns = data.slice(startColumn, endColumn);
const offsetX = startColumn * COLUMN_WIDTH;

3. Intersection Observer

Use for more efficient scroll detection:

useEffect(() => {
  const observer = new IntersectionObserver(handleIntersection);
  observer.observe(sentinelRef.current);
  return () => observer.disconnect();
}, []);

Production-Ready Libraries

While understanding the implementation is valuable, consider using battle-tested libraries for production:

  • react-window: Lightweight, simple API
  • react-virtualized: Feature-rich, handles complex scenarios
  • TanStack Virtual: Modern, framework-agnostic
  • virtua: High performance, supports dynamic heights

Conclusion

Virtual scrolling transforms how we handle large lists in React. By rendering only what's visible, we can create buttery-smooth experiences even with massive datasets. The key principles are:

  1. Track scroll position
  2. Calculate visible range
  3. Render only visible items
  4. Position with CSS transforms
  5. Maintain total height for scrollbar

Whether you implement it yourself or use a library, virtual scrolling is an essential technique for any React developer working with lists. Your users (and their devices) will thank you for the performance boost.