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
- Viewport: The visible area where items are displayed
- Virtual Height: The total height the list would have if all items were rendered
- Visible Range: The subset of items currently in view
- Buffer (Overscan): Extra items rendered above/below the viewport to prevent flickering
- 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 heightVIEWPORT_HEIGHT: Determines how many items are visibleBUFFER: 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 pastMath.floor()= converts to whole item index- BUFFER= includes extra items above for smooth scrollingscrollTop + 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
- Constant Performance: Rendering 100 or 100,000 items has nearly the same performance
- Lower Memory: Only visible items consume memory
- Faster Initial Load: Users see content immediately
- Smooth Scrolling: Less work for the browser during scroll events
- 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:
- Track scroll position
- Calculate visible range
- Render only visible items
- Position with CSS transforms
- 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.