Virtualizing Large Lists in React with react-window
An advanced guide to virtualizing large lists in React using react-window — fixed and variable size lists, performance tuning, and integration with real data sources.
A long rendered list can create substantial DOM, layout, and paint work even when row components are inexpensive. Virtualization limits the mounted rows to a window around the visible area.
This guide uses react-window to demonstrate that approach and the handling of row size and scrolling.
How Virtualization Works
A virtualizing list measures the container's height and the scroll position, calculates which row indices are currently visible, and renders only those components. As the user scrolls, invisible rows are unmounted and new ones are mounted. The DOM stays small regardless of how many items are in the dataset.
This is not a rendering optimization in the traditional React sense — it is a structural change to how many components exist at any given time. A list of 10,000 rows might only have 30 mounted components at any moment.
Installing and Setting Up react-window
npm install react-windowreact-window exports several components. The two most commonly used are:
FixedSizeList— all rows have the same height.VariableSizeList— rows have different heights, provided by a sizing function.
FixedSizeList for Uniform Row Heights
FixedSizeList is the simpler and more performant option. Use it whenever all rows have the same height — chat messages of a fixed line height, table rows, card grids.
import { FixedSizeList } from "react-window";
function Row({ index, style }) {
return (
<div style={style}>
Row {index}: {data[index].name}
</div>
);
}
function ItemList() {
return (
<FixedSizeList
height={600}
itemCount={data.length}
itemSize={48}
width="100%"
>
{Row}
</FixedSizeList>
);
}The style prop injected into Row is critical — it contains the absolute positioning that places each row at the correct scroll offset. Never omit it from the row's root element.
height is the visible container height in pixels. itemSize is the height of each row in pixels. itemCount is the total number of items in the dataset.
VariableSizeList for Dynamic Row Heights
When rows have different heights — expandable list items, posts with variable text length, comment threads — use VariableSizeList. You provide a function that returns the height for a given index.
import { VariableSizeList } from "react-window";
const rowHeights = data.map(item => (item.expanded ? 120 : 48));
function getItemSize(index) {
return rowHeights[index];
}
function VariableList() {
const listRef = useRef();
return (
<VariableSizeList
height={600}
itemCount={data.length}
itemSize={getItemSize}
width="100%"
ref={listRef}
>
{Row}
</VariableSizeList>
);
}When row heights change at runtime — for example, when a user expands a row — call listRef.current.resetAfterIndex(changedIndex). Without this, VariableSizeList continues using stale height values and positions rows incorrectly.
Passing Data to Rows with itemData
Row components receive index and style as props. To pass additional data — the item array, click handlers, selected state — use the itemData prop on the list. The value is forwarded to each row component as a data prop.
const itemData = { items: data, onSelect: handleSelect };
function Row({ index, style, data }) {
const item = data.items[index];
return (
<div style={style} onClick={() => data.onSelect(item.id)}>
{item.name}
</div>
);
}
return (
<FixedSizeList itemData={itemData} ...>
{Row}
</FixedSizeList>
);This is preferable to capturing data in a closure, because itemData can be memoized with useMemo to keep the reference stable and prevent row re-renders.
Memoizing Row Components
Each row component should be wrapped in React.memo. Without it, every row re-renders when the parent list re-renders — even rows that are already mounted and whose data has not changed.
const Row = React.memo(function Row({ index, style, data }) {
return (
<div style={style}>
{data.items[index].name}
</div>
);
});Row-level memoization can help when props remain stable and profiling shows repeated renders are costly. Verify that comparison overhead and changing props do not erase the benefit.
Handling Dynamic Lists and Scroll Restoration
For lists that load more data as the user scrolls — infinite scroll — react-window pairs with react-window-infinite-loader, which coordinates chunk loading with the virtualized scroll position.
For scroll restoration across navigation, save the scroll offset (exposed via onScroll) to a ref or state manager, and restore it using listRef.current.scrollTo(offset) when the component mounts.
When react-window Is Not the Right Tool
Virtualization adds complexity and has constraints: the list must be inside a fixed-height container, and absolute positioning for rows can conflict with some layout approaches. For lists under roughly 200 items, the performance gain from virtualization is often imperceptible, and the added complexity is not worth it.
If rows have unpredictable heights and you cannot measure them reliably before rendering, consider react-virtual (from TanStack), which supports dynamic measurement via intersection observers.
Combining Virtualization with Data Fetching
Virtualized lists work best with paginated or windowed data fetching: fetch the first N items, then fetch more as the user scrolls near the end. This keeps memory usage bounded on the client and avoids loading a dataset too large to manage efficiently.
Libraries like TanStack Query expose hasNextPage and fetchNextPage that integrate cleanly with infinite scroll patterns, complementing the rendering-layer virtualization that react-window provides.
Measure rendering time, memory, and scrolling behavior before and after virtualization. Also test keyboard navigation and the other interactions the list supports.