Infinite Scrolling
by Pratik Rai
Read the full write-upBuild an infinitely scrolling feed. Pages of ten items load as the user reaches the bottom, until there are none left.
fetchPage(page) is given: it resolves after 600ms with { items, hasMore }.
Requirements
The first page loads when the component mounts.
Reaching the bottom of the scroll container loads the next page and appends it.
Only one request is ever in flight — scrolling fast must not fire several.
"Loading…" shows while a page is on its way.
When
hasMoreis false, stop loading and say the feed has ended.The observer is disconnected when the component unmounts.
Notes
The feed scrolls inside its own 380px box, not the page.
IntersectionObserveris the intended approach; a scroll handler measuringscrollTopalso works but is harder to get right.
Hints
Infinite Scrolling (reference solution)
One way to do it, not the only way. Yours passes if it renders.
Solution files
1import { useState, useEffect, useRef, useCallback } from 'react';
2
3const PAGE_SIZE = 10;
4const TOTAL = 45;
5
6// Stands in for a paginated endpoint. The preview has no network, so this is a
7// promise on a timer — which is what a real fetch looks like from here anyway.
8function fetchPage(page) {
9 return new Promise((resolve) => {
10 setTimeout(() => {
11 const start = page * PAGE_SIZE;
12 const items = Array.from(
13 { length: Math.min(PAGE_SIZE, Math.max(0, TOTAL - start)) },
14 (_, i) => ({
15 id: start + i,
16 title: 'Item ' + (start + i + 1),
17 note: 'Loaded on page ' + (page + 1),
18 })
19 );
20 resolve({ items, hasMore: start + items.length < TOTAL });
21 }, 600);
22 });
23}
24
25export default function App() {
26 const [items, setItems] = useState([]);
27 const [loading, setLoading] = useState(false);
28 const [hasMore, setHasMore] = useState(true);
29 const sentinelRef = useRef(null);
30 // The next page to ask for. A ref, not state: nothing renders it, and a
31 // stable loadMore keeps the observer effect from tearing down every page.
32 const pageRef = useRef(0);
33 // Checked and set synchronously. `loading` state is a render behind, so the
34 // observer can fire twice before it flips and both calls would fetch.
35 const inFlightRef = useRef(false);
36
37 const loadMore = useCallback(async () => {
38 if (inFlightRef.current) return;
39 inFlightRef.current = true;
40 setLoading(true);
41 const result = await fetchPage(pageRef.current);
42 pageRef.current += 1;
43 setItems((prev) => [...prev, ...result.items]);
44 setHasMore(result.hasMore);
45 setLoading(false);
46 inFlightRef.current = false;
47 }, []);
48
49 useEffect(() => {
50 loadMore();
51 }, [loadMore]);
52
53 useEffect(() => {
54 const node = sentinelRef.current;
55 if (!node || !hasMore) return undefined;
56
57 const observer = new IntersectionObserver(
58 (entries) => {
59 if (entries[0].isIntersecting) loadMore();
60 },
61 // The scroll container is the root, not the viewport — the feed scrolls
62 // inside itself, so watching the page would never fire.
63 { root: node.closest('.feed'), rootMargin: '120px' }
64 );
65
66 observer.observe(node);
67 return () => observer.disconnect();
68 }, [hasMore, loadMore]);
69
70 return (
71 <div>
72 <h1>Infinite scroll</h1>
73
74 <div className="feed">
75 {items.map((item) => (
76 <div className="row" key={item.id}>
77 <strong>{item.note}</strong>
78 {item.title}
79 </div>
80 ))}
81
82 <div className="sentinel" ref={sentinelRef} aria-live="polite">
83 {loading ? 'Loading…' : ''}
84 </div>
85
86 {!hasMore && <p className="end">That is everything.</p>}
87 </div>
88 </div>
89 );
90}How it works
Three traps, and interviewers usually probe all three.
The first is the observer root. IntersectionObserver defaults to the viewport, and a list that scrolls inside a fixed-height box never intersects it — the sentinel is technically on screen the whole time or never, depending on the layout. Passing the scroll container as root is what makes it fire at the right moment, and rootMargin starts the fetch just before the user hits the end so the wait is hidden.
The second is the duplicate request. Between the intersection firing and loading becoming true, React has to re-render — and the observer can fire again in that gap. A ref is written synchronously, so the second call sees the first immediately. This is the same reason whack-a-mole guards double hits with a ref.
The third is cleanup. Without observer.disconnect(), the observer outlives the component and holds a reference to a callback that sets state on something React has already thrown away.