Virtualized List
by Pratik Rai
Read the full write-upTen thousand rows, and only the ones on screen in the DOM.
The scrollbar has to behave as though every row were rendered — the right length, the right position — while the number of elements stays roughly constant however far down you scroll.
Requirements
Only the rows in view (plus a small buffer) exist in the DOM.
The scrollbar reflects the full list: scrolling to the bottom reaches row 10,000.
Rows appear at the correct position, so the content does not drift as you scroll.
Row height is fixed at
ROW_HEIGHT, which is what makes the maths possible.Show how many rows are currently in the DOM, so the saving is visible.
Notes
This is windowing, not infinite scroll — the data is all available, the problem is the DOM.
Scroll fast and watch for blank space at the edges; that is what the buffer is for.
Hints
Virtualized List (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState } from 'react';
2
3const TOTAL = 10000;
4const ROW_HEIGHT = 36;
5const VIEWPORT_HEIGHT = 320;
6
7// Generated rather than stored: ten thousand objects would dominate the file
8// and the point of the exercise is what reaches the DOM, not the data.
9const rowLabel = (index) => 'Row ' + (index + 1) + ' — item id ' + (1000 + index);
10
11// One screen of rows above and below, so a fast scroll does not show blanks
12// before the next render lands.
13const OVERSCAN = 3;
14
15export default function App() {
16 const [scrollTop, setScrollTop] = useState(0);
17
18 // Which rows the scroll position implies. With a fixed row height this is
19 // arithmetic rather than measurement — no reading the DOM at all.
20 const firstVisible = Math.floor(scrollTop / ROW_HEIGHT);
21 const visibleCount = Math.ceil(VIEWPORT_HEIGHT / ROW_HEIGHT);
22
23 const start = Math.max(0, firstVisible - OVERSCAN);
24 const end = Math.min(TOTAL, firstVisible + visibleCount + OVERSCAN);
25
26 const rows = [];
27 for (let index = start; index < end; index += 1) rows.push(index);
28
29 return (
30 <div className="wrap">
31 <h1>10,000 rows</h1>
32
33 <div className="viewport" onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>
34 {/* Full height so the scrollbar behaves as if every row were present. */}
35 <div className="canvas" style={{ height: TOTAL * ROW_HEIGHT }}>
36 {/* One transform on the window, rather than positioning each row.
37 Cheaper, and it keeps the rows in normal flow so they still
38 stack and separate the way the CSS says. */}
39 <div style={{ transform: 'translateY(' + start * ROW_HEIGHT + 'px)' }}>
40 {rows.map((index) => (
41 <div className={'row' + (index % 2 ? ' row-alt' : '')} key={index}>
42 {rowLabel(index)}
43 </div>
44 ))}
45 </div>
46 </div>
47 </div>
48
49 <p className="stats">
50 In the DOM: <strong>{rows.length}</strong> of {TOTAL} · showing rows {start + 1}–{end}
51 </p>
52 </div>
53 );
54}How it works
Virtualisation is two independent tricks, and describing them separately is most of the answer.
The first is lying to the scrollbar. A container as tall as every row combined makes the browser produce a scrollbar of the right length and a scrollTop in the right range, even though only twenty elements exist. Without it the list scrolls a screen and stops.
The second is deriving the window. Fixed row heights turn "what is visible" into division, so nothing has to be measured and there is no layout read in the scroll path — which is exactly why fixed heights are the version asked for in an interview. Variable heights need measured offsets and a running total, and that is the follow-up, not the starting point.
A single transform on the window rather than absolute positioning per row is worth choosing deliberately: it is one style property instead of n, and the rows stay in normal flow, so borders and nth-child styling still work. Absolute positioning is what forces most hand-rolled virtual lists to reimplement their own separators.
The overscan exists because rendering is not instant. Scroll quickly with it set to zero and you see blank strips at the leading edge while React catches up.
Where this differs from the infinite-scrolling problem: there, the data does not exist yet and the job is fetching more. Here all the data is present and the DOM is the bottleneck. Real lists often need both.