JavaScript and React course Β· Module 9: Modern React Hooks

List virtualization and windowing

17 min read
In this lesson7

Imagine looking through the observation window of a spaceship at billions of stars in the galaxy. You don't see all of them at once - you only see the ones in your field of view. That's exactly what list virtualization in React is about: instead of rendering thousands of DOM elements, we render only the ones currently visible on the screen.

The problem: rendering thousands of elements

When we have a list with thousands of elements (e.g. a list of planets, server logs, chat messages), rendering all of them at once can cause serious performance problems:

1// PROBLEM: Rendering 10,000 elements at once
2function SlowStarCatalog({ stars }) {
3  // This will create 10,000 DOM elements at once!
4  return (
5    <div style={{ height: '600px', overflow: 'auto' }}>
6      {stars.map(star => (
7        <div key={star.id} style={{
8          padding: '12px',
9          borderBottom: '1px solid #333',
10          backgroundColor: '#0a0a1a',
11          color: '#ccc'
12        }}>
13          <strong style={{ color: '#ffd700' }}>{star.name}</strong>
14          <span style={{ marginLeft: '10px' }}>Type: {star.type}</span>
15          <span style={{ marginLeft: '10px' }}>Distance: {star.distance} ly</span>
16        </div>
17      ))}
18    </div>
19  );
20}
21
22// Each of the 10,000 elements:
23// - creates DOM nodes
24// - takes up browser memory
25// - slows down the initial render
26// - slows down scrolling

The effects of this approach are:

  • Long initial render time - the browser has to create thousands of DOM elements
  • High memory usage - every DOM element takes up memory
  • Laggy scrolling - the browser has to manage a huge DOM tree
  • Frozen interface - the user waits for the application to respond

The windowing concept

Windowing is a technique that renders only the elements visible in the "window" (viewport) of a scroll container. The rest of the elements simply don't exist in the DOM - they are added only when the user scrolls the list.

1// CONCEPT: How windowing works
2
3// Imagine a list of 10,000 elements, each 50px tall
4// The container is 500px tall - we only see 10 elements at a time
5
6// WITHOUT windowing:
7// DOM contains: 10,000 elements = 500,000px of height
8// Memory: huge
9
10// WITH windowing:
11// DOM contains: ~15 elements (10 visible + buffer)
12// "Empty" space simulated with padding/transform
13// Memory: minimal
14
15// How it works:
16// [padding-top: simulates elements above]
17// [element 45] <- visible
18// [element 46] <- visible
19// [element 47] <- visible
20// [element 48] <- visible
21// ...
22// [element 54] <- visible
23// [padding-bottom: simulates elements below]

The diagram shows the principle, but there is no magic behind virtualization, just a bit of arithmetic. Before you reach for a library, build such a window yourself.

A window built by hand

You need state with the scroll position and three calculations. The first visible row is the number of scrolled pixels divided by the row height, and to the visible rows you add a few spare ones (overscan) above and below the window, so that fast scrolling never flashes an empty space:

1function getVisibleRange(scrollTop, { rowHeight, viewHeight, count, overscan = 3 }) {
2  const first = Math.floor(scrollTop / rowHeight);
3  const visible = Math.ceil(viewHeight / rowHeight);
4  const start = Math.max(0, first - overscan);
5  const end = Math.min(count, first + visible + overscan);
6  return { start, end };
7}

The function knows nothing about React: for a 400 px window, 40 px rows and a scroll of 4000 px it returns the range from 97 to 113. The component keeps scrollTop in state and renders only that slice of the array:

1function StarWindow({ stars }) {
2  const [scrollTop, setScrollTop] = useState(0);
3  const { start, end } = getVisibleRange(scrollTop, { rowHeight: 40, viewHeight: 400, count: stars.length });
4
5  return (
6    <div style={{ height: 400, overflowY: 'auto' }} onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
7      <div style={{ height: stars.length * 40, position: 'relative' }}>
8        {stars.slice(start, end).map((star, i) => (
9          <div key={star.id} style={{ position: 'absolute', top: (start + i) * 40, height: 40 }}>
10            {star.name}
11          </div>
12        ))}
13      </div>
14    </div>
15  );
16}

The inner div is as tall as the whole list, so the scrollbar behaves as if all the stars were in the DOM. Every row gets a top equal to its index in the full list times the row height, and slice(start, end) does not change the stars array, it only returns a part of it. VirtualizedList in the editor next to this lesson works the same way, and react-window does the same while adding variable heights, grids and accessibility attributes.

react-window - a virtualization library

react-window is a lightweight library by Brian Vaughn (the author of the original react-virtualized) that implements windowing in a simple and efficient way.

Important: version 2 was released in 2025, and its API is completely different from version 1. The FixedSizeList and VariableSizeList components are gone, replaced by a single List component (grids use Grid). If you install the library today, you'll get version 2.x, so below we teach the new API and show the old one at the end of this section for projects still on 1.x.

List - fixed-height elements

The List component takes a few key props. rowComponent is the component that draws a single row, rowCount is the total number of elements, rowHeight is the row height in pixels, and rowProps is an object with extra data that the library passes to every row. The row itself receives index, style and ariaAttributes (accessibility attributes such as role="listitem"):

1import { List } from 'react-window';
2
3// Generate 10,000 stars
4const stars = Array.from({ length: 10000 }, (_, index) => ({
5  id: index,
6  name: `Star-${index + 1}`,
7  type: ['White dwarf', 'Red giant', 'Neutron star', 'Supernova'][index % 4],
8  magnitude: (Math.random() * 20 - 5).toFixed(2)
9}));
10
11// Single row component
12// Receives index, style, ariaAttributes + everything from rowProps
13function StarRow({ index, style, ariaAttributes, stars }) {
14  const star = stars[index];
15  return (
16    <div {...ariaAttributes} style={{
17      ...style,
18      display: 'flex',
19      alignItems: 'center',
20      padding: '0 16px',
21      borderBottom: '1px solid #1a1a3e',
22      backgroundColor: index % 2 === 0 ? '#0a0a1a' : '#0f0f2a',
23      color: '#ccc'
24    }}>
25      <span style={{ flex: 1, color: '#ffd700' }}>{star.name}</span>
26      <span style={{ flex: 1 }}>{star.type}</span>
27      <span style={{ width: '100px', textAlign: 'right' }}>
28        mag: {star.magnitude}
29      </span>
30    </div>
31  );
32}
33
34function VirtualizedStarCatalog() {
35  return (
36    <div style={{ padding: '20px', backgroundColor: '#0f0f23' }}>
37      <h2 style={{ color: '#00d2ff' }}>Star Catalog (10,000 objects)</h2>
38      <List
39        rowComponent={StarRow}     // Row component
40        rowCount={stars.length}    // Number of elements
41        rowHeight={50}             // Height of each element (fixed!)
42        rowProps={{ stars }}       // Data passed to every row
43        style={{ height: 400 }}    // Container height
44      />
45    </div>
46  );
47}

Two things are mandatory here. First, you must pass the style from the props to the row element, because it contains the absolute position and the transform offset that put the row in the right place. Second, List fills the height set in style or the height of its parent, so the container needs one. The width defaults to 100%. In a test with 10,000 stars and a 400px container, the library rendered only 11 rows: 8 visible plus a few spare ones (overscanCount).

A fixed number in rowHeight is the most efficient option, because the library doesn't need to measure element heights - it knows them up front. Use it whenever the list items have a fixed, known height.

rowHeight as a function - variable-height elements

When list items have different heights (e.g. chat messages, posts), version 1 used VariableSizeList. In version 2 we stay with List and pass a function to rowHeight. The library calls it with the row index and the rowProps object:

1import { List } from 'react-window';
2
3const messages = Array.from({ length: 5000 }, (_, i) => ({
4  id: i,
5  sender: ['Captain Nova', 'Engineer Rex', 'Navigator Luna'][i % 3],
6  text: i % 5 === 0
7    ? 'Longer message with mission report - status of propulsion systems, communications, and life support. All parameters within normal range.'
8    : 'Short message #' + (i + 1)
9}));
10
11// Function returning an element's height based on its index
12const getRowHeight = (index, { messages }) => {
13  const message = messages[index];
14  // Longer message = taller cell
15  return message.text.length > 50 ? 80 : 50;
16};
17
18function MessageRow({ index, style, ariaAttributes, messages }) {
19  const msg = messages[index];
20  return (
21    <div {...ariaAttributes} style={{
22      ...style,
23      padding: '8px 16px',
24      borderBottom: '1px solid #1a1a3e',
25      backgroundColor: '#0a0a1a',
26    }}>
27      <strong style={{ color: '#e94560' }}>{msg.sender}:</strong>
28      <p style={{ color: '#ccc', margin: '4px 0 0 0', fontSize: '14px' }}>
29        {msg.text}
30      </p>
31    </div>
32  );
33}
34
35function VirtualizedChat() {
36  return (
37    <List
38      rowComponent={MessageRow}
39      rowCount={messages.length}
40      rowHeight={getRowHeight}
41      rowProps={{ messages }}
42      style={{ height: 400 }}
43    />
44  );
45}

The message row looks the same as before, only the source of the height has changed. If the heights can't be computed up front because they depend on text wrapping, version 2 has a useDynamicRowHeight hook that measures rows after rendering. The documentation warns, however, that this is less efficient than values known in advance, so whenever you can, provide a number or a function.

The older API (react-window 1.x)

If you work on a project whose package.json lists version 1.x, you'll see a notation with separate components there. Instead of rowComponent the row is passed as children, the number of elements is set by itemCount, the height by itemSize, and the container dimensions by the height and width props. Both examples from this lesson looked like this in version 1:

1// react-window 1.x - API removed in version 2
2import { FixedSizeList, VariableSizeList } from 'react-window';
3
4<FixedSizeList
5  height={400}         // Container height
6  width="100%"         // Container width
7  itemCount={stars.length}  // Number of elements
8  itemSize={50}        // Height of each element (fixed!)
9>
10  {StarRow}
11</FixedSizeList>;
12
13<VariableSizeList
14  height={400}
15  width="100%"
16  itemCount={messages.length}
17  itemSize={getItemSize}  // (index) => height
18>
19  {MessageRow}
20</VariableSizeList>

In version 1 the row received only index and style, and read its data from an outer variable (or from the itemData prop). When upgrading to version 2 you replace the components with List, itemCount with rowCount, itemSize with rowHeight, and move the data into rowProps.

When to use virtualization vs pagination?

The choice between virtualization and pagination depends on the context - just like choosing between a telescope and a star map:

Virtualization is better when:

  • The user needs smooth scrolling (e.g. a feed, chat, logs)
  • The data is already loaded (or loaded as a stream)
  • Keeping a "natural" scrollbar is important
  • The list is very long, but the items are simple

Pagination is better when:

  • The data is loaded from the server page by page
  • The user wants to jump to specific pages
  • SEO matters (each page has its own URL)
  • The list items are very complex

Let's compare both approaches in code. Pagination keeps the page number in state and slices only its part out of the array, while virtualization hands the whole array to List:

1// Pagination - the classic approach
2function PaginatedList({ items, pageSize }) {
3  const [page, setPage] = useState(1);
4  const totalPages = Math.ceil(items.length / pageSize);
5
6  return (
7    <div>
8      {/* Render only the current page's items */}
9      {items.slice((page - 1) * pageSize, page * pageSize).map(item => (
10        <ItemCard key={item.id} item={item} />
11      ))}
12      <div>
13        <button disabled={page === 1} onClick={() => setPage(p => p - 1)}>
14          Previous
15        </button>
16        <span>Page {page} of {totalPages}</span>
17        <button disabled={page === totalPages} onClick={() => setPage(p => p + 1)}>
18          Next
19        </button>
20      </div>
21    </div>
22  );
23}
24
25// Virtualization - the modern approach for long lists
26function ItemRow({ index, style, items }) {
27  return (
28    <div style={style}>
29      <ItemCard item={items[index]} />
30    </div>
31  );
32}
33
34function VirtualizedList({ items }) {
35  return (
36    <List
37      rowComponent={ItemRow}
38      rowCount={items.length}
39      rowHeight={60}
40      rowProps={{ items }}
41      style={{ height: 600 }}
42    />
43  );
44}

In both versions there's only a handful of cards in the DOM, what differs is how you move through the data: page buttons versus smooth scrolling. In version 2 you no longer pass a function as children, the row is always a separate component in rowComponent.

Measuring the performance improvement

To prove that virtualization works, we can measure the difference in performance. The component switches between a regular list and List and logs the approximate time until the next frame:

1function NameRow({ index, style, items }) {
2  return <div style={style}>{items[index].name}</div>;
3}
4
5function PerformanceComparison() {
6  const [mode, setMode] = useState('virtualized');
7  const items = useMemo(
8    () => Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Item ${i}` })),
9    []
10  );
11
12  // Measure render time
13  useEffect(() => {
14    const start = performance.now();
15    // After the render
16    requestAnimationFrame(() => {
17      const end = performance.now();
18      console.log(`Render time (${mode}): ${(end - start).toFixed(2)}ms`);
19    });
20  }, [mode]);
21
22  return (
23    <div>
24      <div>
25        <button onClick={() => setMode('normal')}>Normal list</button>
26        <button onClick={() => setMode('virtualized')}>Virtualized</button>
27      </div>
28
29      {mode === 'normal' ? (
30        // 10,000 DOM elements
31        <div style={{ height: 400, overflow: 'auto' }}>
32          {items.map(item => <div key={item.id}>{item.name}</div>)}
33        </div>
34      ) : (
35        // ~15 DOM elements
36        <List
37          rowComponent={NameRow}
38          rowCount={items.length}
39          rowHeight={35}
40          rowProps={{ items }}
41          style={{ height: 400 }}
42        />
43      )}
44    </div>
45  );
46}
47
48// Results depend on the device and browser - measure them yourself.
49// Normal list: time and memory grow with the number of elements
50// Virtualized: work depends on the window height, not the list length

We don't give specific milliseconds here, because results on a laptop and on an old phone differ many times over. The Profiler tab in React DevTools and the browser's Performance panel will give you a more accurate picture.

The performance toolbox: a recap before the exercises

Virtualization is only one of the performance tools. You met some of the others, like the Profiler and code splitting, in the module on advanced patterns, and the exercises after the next lessons come back to the whole toolbox. So let's gather it in one place: from how React renders to work done in the background, off the main thread.

How React renders

Every update goes through the same cycle. First something triggers a render, e.g. a state change with setState. In the render phase React calls the components and gets new JSX, and during reconciliation it compares the new tree with the previous one. In the commit phase it applies only the differences to the DOM, the browser paints the screen, and finally React runs the effects from useEffect. Since React 18 several state updates go into one render (automatic batching) not only in event handlers, but also in setTimeout, in promises and in native events:

1setTimeout(() => {
2  setFuel((fuel) => fuel - 10);
3  setSpeed((speed) => speed + 1);
4  // React 18+: one render instead of two
5}, 1000);

Before, each of these two changes caused its own render. Concurrent rendering goes one step further: React can prepare several versions of the interface at once and interrupt less urgent renders, and useTransition and useDeferredValue from this module rely on it.

Profiler: measure before you optimize

The Profiler component measures how long a part of the tree takes to render. You wrap the measured component in it, and React calls onRender after every render of that part:

1import { Profiler } from 'react';
2
3function onRender(id, phase, actualDuration) {
4  console.log(id + ' [' + phase + ']: ' + actualDuration.toFixed(1) + ' ms');
5}
6
7<Profiler id="StarMap" onRender={onRender}>
8  <StarMap />
9</Profiler>

The phase argument is "mount" on the first render and "update" on the next ones, and actualDuration is the render time in milliseconds. The Profiler tab in React DevTools shows the same data: you open DevTools, go to the Profiler tab, click Start profiling, perform the interaction, stop the recording and read the flamegraph, where the components that take longest to render stand out at once. Only then do you choose a technique: memo, virtualization or code splitting.

Code splitting and bundle size

A bundler such as Vite or webpack builds the bundle in several stages. It analyzes the code and the dependency graph, removes unused exports (tree shaking), splits the code into smaller chunks (code splitting) and minifies them, and finally the server compresses the files with gzip or brotli. Tree shaking only works with ES modules, because import and export are static:

1import { debounce } from 'lodash-es'; // only debounce goes into the bundle
2const _ = require('lodash'); // CommonJS: the whole library goes in

You split a heavy view into a separate chunk with lazy and a dynamic import(). Its code is downloaded only on its first render, and Suspense shows a fallback in the meantime:

1const StarMap = lazy(() => import('./StarMap'));
2
3<Suspense fallback={<p>Loading the map...</p>}>
4  <StarMap />
5</Suspense>

A bundle analyzer, e.g. rollup-plugin-visualizer in Vite or webpack-bundle-analyzer, tells you which modules to split out. It shows the size of every module after compression, so it is easy to find the ones that exceed the budget you set.

Lazily loaded images

Full-size pictures do not have to arrive right away either. The loading="lazy" attribute tells the browser to download an image only when it gets close to the screen. Add a small, blurred thumbnail for the loading time and the onLoad and onError events, which switch the component state:

1<img
2  src={planet.full}
3  alt={planet.name}
4  loading="lazy"
5  onLoad={() => setStatus('loaded')}
6  onError={() => setStatus('error')}
7/>

When you need your own logic, e.g. an animation when an element enters the screen, you can do the same observation with IntersectionObserver.

Measurements in the browser

PerformanceObserver listens to entries from the Performance API, e.g. your own measurements (measure) and page load data (navigation):

1const observer = new PerformanceObserver((list) => {
2  list.getEntries().forEach((entry) => console.log('Performance:', entry));
3});
4observer.observe({ entryTypes: ['measure', 'navigation'] });

The easiest way to collect the Core Web Vitals (LCP, INP, CLS) is the web-vitals library.

Background work: Web Worker and Service Worker

You do not have to run heavy calculations on the thread that draws the interface. A Web Worker is a separate thread you talk to through messages: postMessage sends data, onmessage receives the result in event.data, and terminate stops the thread. In a component you create it in an effect and stop it in the cleanup:

1useEffect(() => {
2  const worker = new Worker(new URL('./route.worker.js', import.meta.url), { type: 'module' });
3  worker.onmessage = (event) => setResult(event.data);
4  workerRef.current = worker;
5  return () => worker.terminate();
6}, []);

A click sends the data with workerRef.current.postMessage(...), and the interface keeps responding, because another thread does the calculation. We will cover worker threads in more depth in the next module. A Service Worker, in turn, sits between the app and the network and decides in the fetch event where to get the response from. Static files usually get the cache-first strategy:

1async function cacheFirst(request, cache) {
2  const cached = await cache.match(request);
3  if (cached) return cached;
4  const response = await fetch(request);
5  await cache.put(request, response.clone());
6  return response;
7}

API data gets the network-first strategy, which works the other way round: it asks the network first and saves a fresh copy with cache.put, and reaches for cache.match only when there is no network. The copy made with response.clone() is needed, because the body of a response can be read only once.

Summary

List virtualization is one of the most effective performance optimization techniques in React:

  1. Render only visible elements - use react-window or react-virtuoso
  2. List with a number in rowHeight for fixed-height elements (the most efficient; in 1.x FixedSizeList)
  3. List with a function in rowHeight for variable-height elements (in 1.x VariableSizeList), and useDynamicRowHeight when heights have to be measured
  4. Measure before and after - use the React DevTools Profiler and performance.now()
  5. Consider pagination as an alternative when data is loaded from the server

Just as the telescope on a spaceship shows us only a fragment of the sky at any given moment, virtualization shows the user only a fragment of the list - but with smoothness worthy of the best navigation systems.

Code for this lesson: App.jsx
1import React, { useState, useRef, useCallback, useEffect } from 'react';
2
3// Generating a large dataset - 10,000 stellar objects
4function generateStarCatalog(count) {
5  const types = ['Star', 'Nebula', 'Galaxy', 'Pulsar', 'Quasar'];
6  const constellations = ['Orion', 'Andromeda', 'Cassiopeia', 'Lyra', 'Cygnus', 'Draco'];
7  const catalog = [];
8  for (let i = 0; i < count; i++) {
9    catalog.push({
10      id: i,
11      name: `${types[i % types.length]}-${(i + 1).toString().padStart(5, '0')}`,
12      type: types[i % types.length],
13      constellation: constellations[i % constellations.length],
14      magnitude: (Math.random() * 15 - 2).toFixed(2),
15      distance: (Math.random() * 100000).toFixed(0),
16    });
17  }
18  return catalog;
19}
20
21// Version WITHOUT virtualization - renders ALL elements
22function NaiveList({ items, itemHeight }) {
23  return (
24    <div style={{
25      height: '300px', overflow: 'auto',
26      border: '1px solid #ff4444', borderRadius: '8px',
27    }}>
28      {items.map(item => (
29        <div key={item.id} style={{
30          height: itemHeight, padding: '0 12px',
31          display: 'flex', alignItems: 'center', gap: '12px',
32          borderBottom: '1px solid #1a2a3c',
33          background: item.id % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent',
34          fontSize: '13px', color: '#c0c8d0',
35        }}>
36          <span style={{ color: '#00d4ff', width: '180px' }}>{item.name}</span>
37          <span style={{ color: '#778da9', width: '100px' }}>{item.constellation}</span>
38          <span style={{ color: '#ffd700', width: '60px' }}>{item.magnitude}m</span>
39          <span style={{ color: '#556677' }}>{Number(item.distance).toLocaleString()} ly</span>
40        </div>
41      ))}
42    </div>
43  );
44}
45
46// Version WITH virtualization - renders ONLY visible elements
47function VirtualizedList({ items, itemHeight, containerHeight }) {
48  const containerRef = useRef(null);
49  const [scrollTop, setScrollTop] = useState(0);
50
51  // We calculate which elements are visible
52  const totalHeight = items.length * itemHeight;
53  const startIndex = Math.floor(scrollTop / itemHeight);
54  const visibleCount = Math.ceil(containerHeight / itemHeight);
55  // We add a buffer - a few extra elements above and below
56  const overscan = 5;
57  const renderStart = Math.max(0, startIndex - overscan);
58  const renderEnd = Math.min(items.length, startIndex + visibleCount + overscan);
59  const visibleItems = items.slice(renderStart, renderEnd);
60  const offsetY = renderStart * itemHeight;
61
62  const handleScroll = useCallback((e) => {
63    setScrollTop(e.target.scrollTop);
64  }, []);
65
66  return (
67    <div
68      ref={containerRef}
69      onScroll={handleScroll}
70      style={{
71        height: containerHeight, overflow: 'auto',
72        border: '1px solid #4ecdc4', borderRadius: '8px',
73        position: 'relative',
74      }}
75    >
76      {/* Empty container at full height - for the scrollbar */}
77      <div style={{ height: totalHeight, position: 'relative' }}>
78        {/* Shifted group of visible elements */}
79        <div style={{
80          position: 'absolute', top: offsetY, left: 0, right: 0,
81        }}>
82          {visibleItems.map(item => (
83            <div key={item.id} style={{
84              height: itemHeight, padding: '0 12px',
85              display: 'flex', alignItems: 'center', gap: '12px',
86              borderBottom: '1px solid #1a2a3c',
87              background: item.id % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent',
88              fontSize: '13px', color: '#c0c8d0',
89            }}>
90              <span style={{ color: '#00d4ff', width: '180px' }}>{item.name}</span>
91              <span style={{ color: '#778da9', width: '100px' }}>{item.constellation}</span>
92              <span style={{ color: '#ffd700', width: '60px' }}>{item.magnitude}m</span>
93              <span style={{ color: '#556677' }}>{Number(item.distance).toLocaleString()} ly</span>
94            </div>
95          ))}
96        </div>
97      </div>
98    </div>
99  );
100}
101
102// Virtualized grid: the same principle as in the list, only every row holds 3 elements
103function VirtualizedGrid({ items, containerHeight }) {
104  const [scrollTop, setScrollTop] = useState(0);
105  const COLUMNS = 3;
106  const ROW_HEIGHT = 70;
107  const overscan = 2;
108
109  const rowCount = Math.ceil(items.length / COLUMNS);
110  const firstRow = Math.floor(scrollTop / ROW_HEIGHT);
111  const visibleRows = Math.ceil(containerHeight / ROW_HEIGHT);
112  const startRow = Math.max(0, firstRow - overscan);
113  const endRow = Math.min(rowCount, firstRow + visibleRows + overscan);
114  // Only the elements of the visible rows go to the DOM
115  const visibleItems = items.slice(startRow * COLUMNS, endRow * COLUMNS);
116
117  return (
118    <div
119      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
120      style={{
121        height: containerHeight, overflow: 'auto',
122        border: '1px solid #ffd700', borderRadius: '8px',
123      }}
124    >
125      <div style={{ height: rowCount * ROW_HEIGHT, position: 'relative' }}>
126        {visibleItems.map((item, i) => {
127          const index = startRow * COLUMNS + i;
128          const row = Math.floor(index / COLUMNS);
129          const column = index % COLUMNS;
130          return (
131            <div key={item.id} style={{
132              position: 'absolute', top: row * ROW_HEIGHT,
133              left: `${(column * 100) / COLUMNS}%`, width: `${100 / COLUMNS}%`,
134              height: ROW_HEIGHT, padding: '6px', boxSizing: 'border-box',
135            }}>
136              <div style={{
137                height: '100%', borderRadius: '6px', padding: '8px',
138                background: 'rgba(255, 215, 0, 0.06)', fontSize: '12px',
139              }}>
140                <div style={{ color: '#00d4ff' }}>{item.name}</div>
141                <div style={{ color: '#778da9' }}>{item.constellation}, {item.magnitude}m</div>
142              </div>
143            </div>
144          );
145        })}
146      </div>
147    </div>
148  );
149}
150
151export default function App() {
152  const [data] = useState(() => generateStarCatalog(10000));
153  const [mode, setMode] = useState('virtual');
154  const ITEM_HEIGHT = 40;
155  const CONTAINER_HEIGHT = 300;
156
157  // We measure render time
158  const [renderTime, setRenderTime] = useState(null);
159  useEffect(() => {
160    const start = performance.now();
161    requestAnimationFrame(() => {
162      setRenderTime((performance.now() - start).toFixed(1));
163    });
164  }, [mode]);
165
166  return (
167    <div style={{
168      minHeight: '100vh',
169      background: 'linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%)',
170      padding: '24px', fontFamily: 'monospace', color: '#e0e1dd',
171    }}>
172      <h1 style={{ color: '#00d4ff', textAlign: 'center', marginBottom: '4px' }}>
173        List Virtualization
174      </h1>
175      <p style={{ color: '#778da9', textAlign: 'center', marginBottom: '20px', fontSize: '13px' }}>
176        Render thousands of elements without a performance drop
177      </p>
178
179      <div style={{ maxWidth: '750px', margin: '0 auto' }}>
180        <div style={{ display: 'flex', gap: '8px', marginBottom: '16px', justifyContent: 'center' }}>
181          {['naive', 'virtual', 'grid'].map(m => (
182            <button
183              key={m}
184              onClick={() => setMode(m)}
185              style={{
186                padding: '8px 16px',
187                background: mode === m ? '#1a3a5c' : 'transparent',
188                border: `1px solid ${mode === m ? '#00d4ff' : '#1a3a5c'}`,
189                borderRadius: '6px',
190                color: mode === m ? '#00d4ff' : '#778da9',
191                cursor: 'pointer', fontSize: '13px',
192              }}
193            >
194              {m === 'naive' ? 'Without virtualization' : m === 'virtual' ? 'Virtualized list' : 'Virtualized grid'}
195            </button>
196          ))}
197        </div>
198
199        <div style={{
200          padding: '10px 16px', marginBottom: '12px',
201          background: 'rgba(0, 212, 255, 0.08)', borderRadius: '8px',
202          display: 'flex', justifyContent: 'space-between', fontSize: '13px',
203        }}>
204          <span>Elements: <strong style={{ color: '#ffd700' }}>{data.length.toLocaleString()}</strong></span>
205          <span>Rendered DOM: <strong style={{ color: mode === 'naive' ? '#ff4444' : '#4ecdc4' }}>
206            {mode === 'naive' ? data.length.toLocaleString() : mode === 'virtual' ? 'up to 18' : 'up to 27'}
207          </strong></span>
208          {renderTime && <span>Time: <strong style={{ color: '#00d4ff' }}>{renderTime}ms</strong></span>}
209        </div>
210
211        {mode === 'naive' && (
212          <>
213            <p style={{ color: '#ff4444', fontSize: '12px', marginBottom: '8px' }}>
214              Warning: rendering {data.length.toLocaleString()} DOM elements may be slow!
215            </p>
216            <NaiveList items={data.slice(0, 500)} itemHeight={ITEM_HEIGHT} />
217            <p style={{ color: '#778da9', fontSize: '11px', marginTop: '4px' }}>
218              (Limited to 500 for browser safety)
219            </p>
220          </>
221        )}
222
223        {mode === 'virtual' && (
224          <VirtualizedList
225            items={data}
226            itemHeight={ITEM_HEIGHT}
227            containerHeight={CONTAINER_HEIGHT}
228          />
229        )}
230
231        {mode === 'grid' && (
232          <VirtualizedGrid items={data} containerHeight={CONTAINER_HEIGHT} />
233        )}
234
235        <div style={{
236          marginTop: '20px', padding: '16px',
237          background: 'rgba(255, 215, 0, 0.06)', borderRadius: '10px',
238          fontSize: '13px', color: '#c0c8d0',
239        }}>
240          <strong style={{ color: '#ffd700' }}>How virtualization works:</strong>
241          <ul style={{ margin: '8px 0', paddingLeft: '20px', lineHeight: '1.8' }}>
242            <li>Instead of rendering 10,000 elements, we render only the visible ones and a few spare ones</li>
243            <li>We use one large container (phantom) for the scrollbar</li>
244            <li>We shift the visible elements to the correct position</li>
245            <li>Overscan (buffer) prevents flickering during fast scrolling</li>
246          </ul>
247        </div>
248      </div>
249    </div>
250  );
251}

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What does the list virtualization (windowing) technique in React involve?

  2. 2. How does the react-window library implement list virtualization?

Hands-on tasks in the game

  • Code editor

    The catalog has 100,000 stars, but the list window fits only 10 rows of 40 px. Complete the virtualization: ___BLANK1___ is the state value that tells how many pixels the window has been scrolled, ___BLANK2___ is the array method that cuts out the stars from index start to end, and ___BLANK3___ is the index of the star in the full list (a field of the star object) that determines the top position of the row. Only a dozen or so rows should be in the DOM, and after scrolling the right stars should appear at the right height. Scroll the list in the preview and check the star numbers.

  • Horizontal ordering

    Arrange the syntax of the List component from react-window 2.x:

  • Code editor

    The crew registration form consists of three CrewField fields. Every field has a label and a helper text, and the component can appear on the screen many times. Fill in the blanks: ___BLANK1___ is the React hook that generates a unique and stable identifier for this instance of the field, ___BLANK2___ is the identifier the label points to through htmlFor, and ___BLANK3___ is the identifier of the helper text paragraph that the field points to through aria-describedby. Clicking a label in the preview should put the cursor in the field, and two such forms on one page must not share identifiers.

Useful articles