Skip to main content

Optimize Large Lists with useDeferredValue

Rendering a list of 100,000 items will freeze any browser. But with useDeferredValue combined with virtual scrolling and smart memoization, you can handle massive lists smoothly. This article teaches you the production patterns: how to defer list updates, render only visible items, and keep scrolling fluid even while filtering.

The principle: defer expensive list re-renders, virtual-scroll to render only viewport items, and memoize list item components to prevent unnecessary re-renders.

Deferred Filtering + Virtual Scrolling

Combine useDeferredValue for automatic debouncing with a virtual list to render only visible items:

import { useMemo, useDeferredValue } from 'react';
import { FixedSizeList as List } from 'react-window';

export function VirtualFilteredList() {
const [searchQuery, setSearchQuery] = React.useState('');
const deferredQuery = useDeferredValue(searchQuery);

// Generate a large dataset
const allItems = useMemo(() => {
return Array.from({ length: 1000000 }, (_, i) => ({
id: i,
name: `Item ${i}`,
description: `Description for item ${i}`,
}));
}, []);

// Filter based on deferred query
const filteredItems = useMemo(() => {
if (!deferredQuery) return allItems;

return allItems.filter(item =>
item.name.toLowerCase().includes(deferredQuery.toLowerCase())
);
}, [allItems, deferredQuery]);

// Detect when a deferred render is pending
const isFiltering = searchQuery !== deferredQuery;

// Virtual list row renderer
const Row = ({ index, style }) => {
const item = filteredItems[index];
return (
<div
style={{
...style,
padding: '8px',
borderBottom: '1px solid #eee',
backgroundColor: index % 2 === 0 ? '#fff' : '#f9f9f9',
}}
>
<strong>{item.name}</strong>
<div style={{ fontSize: '12px', color: '#999' }}>
{item.description}
</div>
</div>
);
};

return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Virtual List with Deferred Filtering</h1>

<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Filter 1 million items..."
style={{
width: '100%',
padding: '10px',
fontSize: '14px',
marginBottom: '10px',
border: '1px solid #ccc',
borderRadius: '4px',
}}
/>

{isFiltering && (
<div style={{
padding: '10px',
backgroundColor: '#f0f0f0',
marginBottom: '10px',
color: '#666',
borderRadius: '4px',
}}>
Filtering... ({filteredItems.length} results)
</div>
)}

<div style={{ marginBottom: '10px', color: '#666' }}>
Found {filteredItems.length} item{filteredItems.length !== 1 ? 's' : ''}
</div>

{filteredItems.length > 0 ? (
<List
height={600}
itemCount={filteredItems.length}
itemSize={60}
width="100%"
>
{Row}
</List>
) : (
<p>No items match your search.</p>
)}
</div>
);
}

How this works:

  1. Input updates immediately (not deferred) → input feels snappy.
  2. deferredQuery stays at the old value while the filter re-computes.
  3. Filter uses useMemo, which only re-runs when deferredQuery changes.
  4. Virtual list renders only ~10 rows visible on screen, not all 1M.
  5. Scrolling through 1M items is smooth because only viewport rows are in the DOM.

Memoizing List Item Components

List item components should be memoized to prevent unnecessary re-renders:

import React, { useMemo, useDeferredValue } from 'react';
import { FixedSizeList as List } from 'react-window';

// Memoized list item—only re-renders if its data changes
const ListItem = React.memo(function ListItem({ item, style }) {
return (
<div
style={{
...style,
padding: '10px',
borderBottom: '1px solid #eee',
backgroundColor: item.id % 2 === 0 ? '#fff' : '#f9f9f9',
}}
>
<div>
<strong>{item.name}</strong>
<span style={{ marginLeft: '10px', color: '#999' }}>
ID: {item.id}
</span>
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{item.description}
</div>
</div>
);
});

export function MemoizedVirtualList() {
const [query, setQuery] = React.useState('');
const deferredQuery = useDeferredValue(query);

const items = useMemo(
() => Array.from({ length: 500000 }, (_, i) => ({
id: i,
name: `Item ${i}`,
description: `Details for item ${i}`,
})),
[]
);

const filtered = useMemo(
() =>
items.filter(item =>
item.name.toLowerCase().includes(deferredQuery.toLowerCase())
),
[items, deferredQuery]
);

const Row = ({ index, style }) => (
<ListItem item={filtered[index]} style={style} />
);

return (
<div style={{ padding: '20px' }}>
<h1>Memoized Virtual List</h1>

<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Filter..."
style={{
width: '100%',
padding: '10px',
marginBottom: '10px',
border: '1px solid #ccc',
}}
/>

<p>{filtered.length} results</p>

<List
height={600}
itemCount={filtered.length}
itemSize={80}
width="100%"
>
{Row}
</List>
</div>
);
}

Without React.memo, scrolling through the list would cause all visible rows to re-render constantly. With memo, only rows with new item data re-render.

Manual Windowing for Fine-Grained Control

If react-window is overkill, implement manual windowing:

import { useMemo, useDeferredValue, useState, useEffect, useRef } from 'react';

export function ManualVirtualList() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);

const items = useMemo(
() => Array.from({ length: 100000 }, (_, i) => ({
id: i,
name: `Item ${i}`,
})),
[]
);

const filtered = useMemo(
() =>
items.filter(item =>
item.name.toLowerCase().includes(deferredQuery.toLowerCase())
),
[items, deferredQuery]
);

// Constants for rendering
const itemHeight = 50;
const containerHeight = 600;
const itemsPerViewport = Math.ceil(containerHeight / itemHeight);

// Calculate which items to render
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(startIndex + itemsPerViewport + 1, filtered.length);
const visibleItems = filtered.slice(startIndex, endIndex);

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

return (
<div style={{ padding: '20px' }}>
<h1>Manual Virtual List</h1>

<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Filter..."
style={{
width: '100%',
padding: '10px',
marginBottom: '10px',
}}
/>

<div
ref={containerRef}
onScroll={handleScroll}
style={{
height: containerHeight,
overflow: 'auto',
border: '1px solid #ccc',
}}
>
{/* Spacer for scrolled-past items */}
<div style={{ height: startIndex * itemHeight }} />

{/* Visible items */}
{visibleItems.map(item => (
<div
key={item.id}
style={{
height: itemHeight,
padding: '10px',
borderBottom: '1px solid #eee',
}}
>
{item.name}
</div>
))}

{/* Spacer for items below viewport */}
<div
style={{
height: Math.max(0, (filtered.length - endIndex) * itemHeight),
}}
/>
</div>

<p style={{ marginTop: '10px', color: '#666' }}>
{filtered.length} items total, showing {visibleItems.length} in viewport
</p>
</div>
);
}

This implementation:

  1. Calculates which items are in the viewport based on scroll position.
  2. Renders only those items plus a small buffer.
  3. Uses spacer divs to maintain correct scroll behavior.
  4. Works with any list size, limited only by browser memory.

Combining with useTransition for Server Filters

When filtering happens on the server, use useTransition for the state update:

import { useState, useTransition, useMemo } from 'react';

export function ServerFilteredList() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState([]);

const handleSearch = (e) => {
const newQuery = e.target.value;
setQuery(newQuery);

startTransition(async () => {
const response = await fetch(`/api/search?q=${newQuery}`);
const data = await response.json();
setResults(data.items);
});
};

return (
<div style={{ padding: '20px' }}>
<h1>Server-Filtered List</h1>

<input
value={query}
onChange={handleSearch}
placeholder="Search (server-side)..."
style={{ width: '100%', padding: '10px', marginBottom: '10px' }}
/>

{isPending && <p>Searching...</p>}

<ul>
{results.slice(0, 50).map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>

{results.length > 50 && (
<p style={{ fontSize: '12px', color: '#999' }}>
Showing 50 of {results.length}
</p>
)}
</div>
);
}

Key Takeaways

  • Use useDeferredValue to automatically debounce filter updates, keeping the input responsive.
  • Virtual scrolling (via react-window or manual implementation) renders only visible items, handling lists of any size.
  • Memoize list item components with React.memo to prevent re-renders when data hasn't changed.
  • Combine useMemo for the filtered list with virtual scrolling for maximum performance.
  • For server-side filtering, wrap the fetch and state update in useTransition.

Frequently Asked Questions

Why is my virtual list scrolling janky?

Check if your list item component is memoized. Without memo, every item re-renders on scroll. Also, ensure your Row component or renderer is not redefined on every render.

Can I use useDeferredValue without virtual scrolling?

Yes, but it won't help with performance if you're rendering all items. Virtual scrolling does the heavy lifting. Defer the value, virtual-scroll, and memoize items.

Should I use react-window or manual windowing?

Use react-window for simplicity. Manual windowing gives more control but requires more code. For most cases, react-window is sufficient.

How do I handle dynamic item heights?

react-window has VariableSizeList for dynamic heights. Manual windowing requires estimating heights and adjusting spacers.

Further Reading