List Virtualization: The Concept

Render only the rows on screen so a 100,000-item list stays smooth.

The fastest way to render a huge list is to not render most of it. Virtualization (also called windowing) keeps in the DOM only the handful of rows currently visible, plus a small buffer above and below. Scroll, and it swaps rows in and out. A list of 100,000 items then costs about the same as a list of 20.

Why the naive list dies

Mapping 100,000 items to 100,000 DOM nodes creates enormous layout, paint, and memory pressure — and every re-render must reconcile all of them. Browsers choke well before that. Pagination avoids it but changes the UX; virtualization keeps the infinite-scroll feel while paying only for what is seen.

How windowing works, conceptually

  1. Measure the scroll container's height and the (fixed or estimated) row height.
  2. From scrollTop, compute the first and last visible index.
  3. Render only that slice, absolutely positioned at the right offset.
  4. Give the container a total height (rows x rowHeight) so the scrollbar reflects the full list.
const first = Math.floor(scrollTop / rowHeight);
const count = Math.ceil(viewportHeight / rowHeight) + overscan;
const visible = items.slice(first, first + count);

Use a library, understand the idea

Rolling your own gets tricky fast with variable row heights, sticky headers, and horizontal grids. Reach for @tanstack/react-virtual or react-window — they are tiny and battle-tested. Your job is to recognize when a list needs windowing (hundreds to thousands of rows, or heavy rows) and to keep row components cheap and memo-friendly so recycling stays fast.

Example

Example · javascript
import { useState, useRef } from 'react';

// A minimal fixed-height virtual list to show the core idea.
function VirtualList({ items, rowHeight = 32, height = 320, overscan = 4 }) {
  const [scrollTop, setScrollTop] = useState(0);
  const total = items.length * rowHeight;

  const first = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
  const visibleCount = Math.ceil(height / rowHeight) + overscan * 2;
  const slice = items.slice(first, first + visibleCount);

  return (
    <div
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
      style={{ height, overflow: 'auto', position: 'relative' }}
    >
      {/* Spacer gives the scrollbar the full height. */}
      <div style={{ height: total }}>
        {slice.map((item, i) => {
          const index = first + i;
          return (
            <div
              key={item.id}
              style={{
                position: 'absolute',
                top: index * rowHeight,
                height: rowHeight,
              }}
            >
              {item.label}
            </div>
          );
        })}
      </div>
    </div>
  );
}
// In production, prefer @tanstack/react-virtual for variable heights & grids.

When to use it

  • An activity feed displaying 50,000 log entries renders smoothly at 60 fps with react-window because only the ~20 visible rows are in the DOM at any time.
  • A spreadsheet-style table with 10,000 rows and 30 columns uses windowing so initial paint takes milliseconds instead of freezing the browser tab.
  • A social media timeline with complex card components per post uses virtualization to avoid mounting thousands of cards up-front, keeping memory usage constant as the list grows.

More examples

FixedSizeList with react-window

FixedSizeList only renders the rows that fit in the 600 px viewport plus a small buffer, so 100,000 items cost the same as rendering 20.

Example · js
import { FixedSizeList } from 'react-window';

function Row({ index, style }) {
  return (
    <div style={style} className="list-row">
      Item #{index}
    </div>
  );
}

function VirtualList({ items }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={items.length}
      itemSize={48}
      width="100%"
    >
      {Row}
    </FixedSizeList>
  );
}

Passing data to virtualised rows

Passing the array via itemData avoids closure-per-row: every row reads from the same stable reference, preventing unnecessary re-renders from new function instances.

Example · js
import { FixedSizeList } from 'react-window';

function UserRow({ index, style, data }) {
  const user = data[index];
  return (
    <div style={style} className="user-row">
      <img src={user.avatar} alt="" />
      <span>{user.name}</span>
    </div>
  );
}

function UserList({ users }) {
  return (
    <FixedSizeList
      height={500}
      itemCount={users.length}
      itemSize={56}
      itemData={users}
      width={400}
    >
      {UserRow}
    </FixedSizeList>
  );
}

Variable-height list with VariableSizeList

VariableSizeList accepts an itemSize function so each row can have a different height, handling mixed content types like headers and body rows.

Example · js
import { VariableSizeList } from 'react-window';

const rowHeights = items.map((item) =>
  item.type === 'header' ? 80 : 48
);

function VarList() {
  return (
    <VariableSizeList
      height={600}
      itemCount={items.length}
      itemSize={(index) => rowHeights[index]}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>{items[index].text}</div>
      )}
    </VariableSizeList>
  );
}

Discussion

  • Be the first to comment on this lesson.