Skip to content

UI build: infinite scroll with a virtualized list

The window is arithmetic on scrollTop; the loader is a flag, a cursor and a stop: one request per page, none after the last.

Predict first

Before you read: predict

ExamHall's proctor activity log for a 3-hour exam holds all 30,000 of its events, oldest first, one row per event. It is windowed like TanStack Virtual, with overscan: 3: every row is 40 px tall, and the log's scroll area is 400 px tall. The proctor scrolls the log to scrollTop 1,020.

How many event rows are in the DOM?

Pick one

The 30-second version

With fixed-height rows the window is arithmetic. Rows on screen run from scrollTop ÷ h to (scrollTop + height − 1) ÷ h, rounded down, so a cut row counts. Widen by overscan each side, clamped to the list, and move the block to start × h. Ask for the next page near the end, one request at a time, and stop at next: null.

The window is arithmetic on scrollTop; the loader is a flag, a cursor and a stop: one request per page, none after the last.

How it works

Step 1 of 13

Rows have places. With a fixed row height h, row i starts at i * h; the inner box is the prerequisite's (fsd-pagination-virtualization step 3).

Analogy: A sawmill's log deck (timber processing)

At a sawmill, logs ride a chain deck past the sawyer's cab. Her window shows a few logs at a time, and trucks bring further loads from the forest landing when she calls.

At the log deck In the list
a deck of 30,000 logs, ten in the window at once the loaded rows, and the rows on screen
the chain counter in centimetres, divided by the log spacing, names the log at the window's near edge; a log half in view still shows first and last from scrollTop (step 2)
the deck lamps also light three logs past each edge of the window, but before the first log there is only bare chain overscan, clamped (step 3)
the tally board pins the first lit log at its own place on the chain, not at the counter reading the block at start * h (step 5)
the call for the next truck goes out when the "deck low" mark passes, early enough for the slowest truck the threshold (steps 7–8)
the "truck called" lamp stays lit until the truck comes back, loaded or empty the flag, cleared in finally (steps 9–10)
the contract's last load carries a red tag, and no call follows it stop at next: null (step 11)

In real terms: compute the window from scrollTop on every render; guard the loader with a ref, clear it in finally, and stop at null.

Breaks down when: trucks can arrive in any order; a cursor list appends its pages strictly in order, each from the reply before it.

Contrasting case: a few logs carried in by hand and sawn where they lie: a list of a few hundred rows rendered in full (fsd-pagination-virtualization step 10).

Examples, run for real

4.1 ExamHall EXH-3560: the activity log at 30,000 events

The proctor activity log of a 3-hour exam reaches 30,000 events. Its first draft rendered every event as an <li> (EXH-3560). MR !3860 windows it: 40 px rows, a 400 px scroll area, overscan 3.

4.1 EXH-3560 draft, every event a row · 30,000 events · <li> in the DOM 30,000
4.1 MR !3860 windowed, 40 px rows, 400 px area, overscan 3 · at scrollTop 0: <li> 13 (events 1–13), block at 0 px · at scrollTop 600,000: <li> 16 (events 14,998–15,013), block at 599,880 px
4.1 the same positions in @tanstack/virtual-core 3.17.11 · at 0: items 1–13 (1-based), first start 0 px · at 600,000: items 14,998–15,013 (1-based), first start 599,880 px

At the top only the overscan below exists (step 3); in the middle a whole number of rows fills the screen and overscan goes on both sides, and the block sits at its first row (step 5). The library gives the same range.

4.2 Media streaming: a watch history loading by cursor

A video app's watch history loads 50 entries a page by cursor; the viewer has 180. The reference loader runs on it while each reply is held until the script releases it:

4.2 on mount: request 1 with the cursor null · entries held 50
4.2 near the end, 25 scroll events while page 2's reply is held · requests sent 1 (cursor after:50) · entries held after it lands 100
4.2 near the end, 25 scroll events while page 3's reply is held · requests sent 1 (cursor after:100) · entries held after it lands 150
4.2 near the end, 25 scroll events while page 4's reply is held · requests sent 1 (cursor after:150) · entries held after it lands 180
4.2 at the bottom after the page whose next is null: 10 more scroll events · requests sent 0 · entries held 180, the last row "entry 180"

One request goes out per page, however many scroll events arrive while it is on its way (step 9); each page carries the cursor the reply before it returned (step 7), and after the page whose next is null the list asks for nothing (step 11).

Try it

Answer first, then check. Nothing is saved.

Question 1 of 8

A video app's watch history holds all 500 of its entries for one viewer, one row each. It is windowed like TanStack Virtual, with overscan: 4: every row is 72 px tall, and the scroll area is 504 px tall. The mounted rows sit in one <ul>, moved down with transform: translateY(…) so that each row lands on its own place in the list. The viewer scrolls to scrollTop 3,000.

What translateY does the <ul> get?

Pick one

Question 2 of 8

MR !3861 adds infinite loading to ExamHall's proctor activity log. fetchPage(cursor) resolves to { events, next } when the reply arrives, a few hundred milliseconds later, and a proctor's scroll fires a scroll event on every frame in that time; the cursor null asks for the first page, and the last page's next is null. The log's new code:

Four review comments came in.

Which one names a real defect?

Code for this question
const ROW = 40, VIEW = 400, OVERSCAN = 3, AHEAD = 20; function ActivityLog({ fetchPage }) {  const [events, setEvents] = useState([]);  const [scrollTop, setScrollTop] = useState(0);  const cursor = useRef(null);  const done = useRef(false);  const loading = useRef(false);   async function loadMore() {    if (loading.current || done.current) return;    try {      const page = await fetchPage(cursor.current);      loading.current = true;      cursor.current = page.next;      done.current = page.next === null;      setEvents((held) => [...held, ...page.events]);    } finally {      loading.current = false;    }  }   useEffect(() => { loadMore(); }, []);   const first = Math.floor(scrollTop / ROW);  const last = Math.floor((scrollTop + VIEW - 1) / ROW);  const start = Math.max(0, first - OVERSCAN);  const end = Math.min(events.length - 1, last + OVERSCAN);   function onScroll(e) {    const top = e.currentTarget.scrollTop;    setScrollTop(top);    if (Math.floor((top + VIEW - 1) / ROW) >= events.length - 1 - AHEAD) loadMore();  }   return (    <div style={{ height: VIEW, overflowY: 'auto' }} onScroll={onScroll}>      <div style={{ height: events.length * ROW }}>        <ul style={{ transform: `translateY(${start * ROW}px)` }}>          {events.slice(start, end + 1).map((ev) => (            <li key={ev.id} style={{ height: ROW }}>{ev.text}</li>          ))}        </ul>      </div>    </div>  );}
Pick one

Question 3 of 8

A payments ledger's transaction log is a windowed list with 40 px rows, a 400 px scroll area and overscan: 3. It loads pages of 100 transactions by cursor, and asks for the next page when the last row on screen comes within threshold rows of the last loaded row. The interviewer's next follow-up: "Operators flick through the log at 2,000 px a second without stopping, from the top, with the first page loaded. The ledger API answers a page in 320 ms at the median and 900 ms at the 99th percentile. Pick threshold so that, even when every reply takes the 99th-percentile time, no operator reaches the bottom of the loaded rows and has to wait".

What is the smallest threshold that keeps every operator from waiting?

Pick one

Question 4 of 8

A chat room's history window:

The room holds 2,000 messages, oldest first, and its history opens at the top, at scrollTop 0.

What does the history's scroll area show when it opens?

Code for this question
const ROW = 48, VIEW = 480, OVERSCAN = 2; function History({ messages }) {  const [scrollTop, setScrollTop] = useState(0);  const first = Math.floor(scrollTop / ROW);  const last = Math.floor((scrollTop + VIEW - 1) / ROW);  const start = first - OVERSCAN;  const end = Math.min(messages.length - 1, last + OVERSCAN);  return (    <div style={{ height: VIEW, overflowY: 'auto' }} onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}>      <div style={{ height: messages.length * ROW }}>        <ul style={{ transform: `translateY(${start * ROW}px)` }}>          {messages.slice(start, end + 1).map((m) => (            <li key={m.id} style={{ height: ROW }}>{m.text}</li>          ))}        </ul>      </div>    </div>  );}
Pick one

Question 5 of 8

the interviewer says: "Build VirtualLog({ fetchPage, rowHeight, height, overscan, threshold }). fetchPage(cursor) resolves to { rows, next } or rejects; the cursor null asks for the first page, and the last page's next is null; each row is { id, text }. The outermost element is the scroll area, height px tall; inside it, a box as tall as every loaded row; inside that, one <ul> moved down with transform: translateY(…px), holding an <li> with the row's text for each mounted row. Mount the rows on screen plus overscan rows on each side. Load the first page on mount and the next one when the last row on screen comes within threshold rows of the last loaded row. Never ask for a page twice, never ask after the last page, and after a failed request ask again on the next scroll near the end." You have 35 minutes, and ExamHall's proctor activity log is the use case.

Before you type: say your test list out loud.

Name at least six tests, each with what it does and what it expects.

Question 6 of 8

the interviewer says: "Build VirtualLog({ fetchPage, rowHeight, height, overscan, threshold }). fetchPage(cursor) resolves to { rows, next } or rejects; the cursor null asks for the first page, and the last page's next is null; each row is { id, text }. The outermost element is the scroll area, height px tall; inside it, a box as tall as every loaded row; inside that, one <ul> moved down with transform: translateY(…px), holding an <li> with the row's text for each mounted row. Mount the rows on screen plus overscan rows on each side. Load the first page on mount and the next one when the last row on screen comes within threshold rows of the last loaded row. Never ask for a page twice, never ask after the last page, and after a failed request ask again on the next scroll near the end." You have 35 minutes, and ExamHall's proctor activity log is the use case.

Now build it, in 35 minutes, from a blank file that exports VirtualLog, with useState, useRef and useEffect in scope.

Question 7 of 8

A shop's admin holds all 2,000 of its products, one row each. The list is windowed like TanStack Virtual, with overscan: 4: every row is 50 px tall, and the scroll area is 600 px tall. The clerk scrolls to scrollTop 99,320.

How many product rows are in the DOM?

Pick one

Question 8 of 8

A box office's support screen lists a customer's orders, newest first, 25 to a page. fetchPage(cursor) resolves to { orders, next }; the first page is asked for with the cursor null, and the last page's next is null. Its loader:

The list calls loadMore on mount, and from its scroll handler whenever the last row on screen is within 5 rows of the last loaded row. This customer has 60 orders. The agent opens the list, then three times scrolls to the bottom of it and waits until it stops growing.

What does the list hold then?

Code for this question
const cursor = useRef(null);const loading = useRef(false); async function loadMore() {  if (loading.current) return;  loading.current = true;  try {    const page = await fetchPage(cursor.current);    cursor.current = page.next;    setOrders((held) => [...held, ...page.orders]);  } finally {    loading.current = false;  }}
Pick one

Go deeper

Pro tips
  1. Do the arithmetic aloud, at the top and mid-row. At the top of 10,000 rows of 40 px in 400 px with overscan 3: 400 ÷ 40 + 3 = 13 rows; mid-list add 3 above, and one more when a row is cut. When: the first minute of the build, before you write the slice (steps 2–3). Verify (RAN, tips.mjs):
    tip 1 reference build, 10,000 rows of 40 px, 400 px area, overscan 3 · at the top 400 / 40 + 3 = 13 rows · at scrollTop 4,000 16 (3 more above) · at 4,020 17 (one row cut)
    (The curriculum row's tip, [RAN], re-executed here.)
  2. Run your build against the list before you call it done: LOG_IMPL=./my-log.jsx node --test log.test.mjs. When: practising the build at home, against the tests the reference passes (step 13). Verify (RAN, tips.mjs):
    tip 2 LOG_IMPL=./drafts/m0-empty.jsx (renders nothing): exit 1 · tests 10 · pass 0 · fail 10
Source

Docs and guides change, so their claims are labelled as of 2026-10-12; every behaviour this lesson grades is executed.

Checked against the tools and docs as of 2026-10-12.