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?
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
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?
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?
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> );}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?
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?
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> );}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?
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?
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; }}Go deeper
Pro tips
- 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):
(The curriculum row's tip, [RAN], re-executed here.)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) - 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.
- TanStack Virtual (the curriculum row's first source), https://tanstack.com/virtual/latest (
tanstack-virtual-latest.txt), with its Virtualizer API page https://tanstack.com/virtual/latest/docs/api/virtualizer (tanstack-virtualizer-api.txt) and Introduction https://tanstack.com/virtual/latest/docs/introduction (tanstack-virtual-intro.txt).overscan: "The number of items to render above and below the visible area." and "The default value is 1." (step 3; every "windowed like TanStack Virtual" stem states its overscan). As of 2026-10-12. - MDN, Intersection Observer API (the row's second source), https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API (
mdn-intersection-observer.txt). Among its uses: "Implementing "infinite scrolling" websites, where more and more content is loaded and rendered as you scroll, so that the user doesn't have to flip through pages." This lesson's build triggers from the scroll handler instead; a sentinel observed this way fires again while a page is on its way, whichfsd-sc-news-feed-uiowns. As of 2026-10-12. - W3C WAI-ARIA Authoring Practices, Feed Pattern (the row's third source), https://www.w3.org/WAI/ARIA/apg/patterns/feed/ (
apg-feed.txt). "Each article element has aria-setsize set to a value that represents either the total number of articles that have been loaded or the total number in the feed"; "If the total number in the feed is undetermined, it can be represented by a aria-setsize value of -1." What a screen reader then says is not claimed or graded here (CL-072). As of 2026-10-12. - MDN, Element: scrollTop, https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollTop (
mdn-scrolltop.txt). "gets or sets the number of pixels by which an element's content is scrolled from its top edge"; "If the element isn't scrolled at all up or down, then scrollTop is 0." (step 2). As of 2026-10-12. - MDN, Array.prototype.slice(), https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice (
mdn-array-slice.txt). "Negative index counts back from the end of the array — if -array.length <= start < 0, start + array.length is used." (step 4). As of 2026-10-12. - MDN, try...catch, https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/try...catch (
mdn-try-catch.txt). "The code in the finally block will always be executed before control flow exits the entire construct." (step 10). As of 2026-10-12.
Related lessons
Checked against the tools and docs as of 2026-10-12.