How would you implement infinite scrolling in React?
Advertisement
🧩 Scenario
🧠 Architecture Walkthrough
Why IntersectionObserver beats scroll event listeners
The classic approach to infinite scroll is to listen to the window.scroll event and compute whether scrollTop + windowHeight >= documentHeight. This works, but it has serious performance problems. Scroll events fire at the browser's repaint rate potentially 60 or 120 times per second and each handler invocation reads from the DOM to get layout values, which forces layout recalculation.
Even with requestAnimationFrame throttling, scroll listeners run on the main thread and can block rendering. IntersectionObserver is fundamentally different: the browser runs the intersection check off the main thread during the compositor phase and only invokes the callback when the observed element's visibility crosses a threshold.
The callback fires maybe once per page load event, not hundreds of times during a scroll gesture. The rootMargin: '100px' configuration in the demo pre-triggers the callback 100 pixels before the sentinel becomes visible, giving the API call time to complete before the user actually hits the bottom of the list.
The callback ref pattern and why plain useRef fails here
The useIntersectionObserver hook returns [setRef, isIntersecting] where setRef is a state setter function used as a ref callback. When React sees a function as a ref prop, it calls that function with the DOM element when the element mounts and with null when it unmounts.
The hook stores this element in state and the useEffect that creates the IntersectionObserver has ref (the DOM element) as a dependency. This means the observer is only created after the sentinel element is actually in the DOM, and it is recreated if the sentinel element changes identity.
If you used a plain useRef instead, the useEffect would not list the ref as a dependency (refs are mutable, not reactive), so the effect would run once on mount but if the sentinel element mounts slightly after the effect runs (common when conditional rendering is involved), the observer would be watching null and never fire.
Race conditions and the loading guard
The loading state is what prevents duplicate requests, but it creates a subtle race condition with the intersection-triggered effect. Consider the sequence: the sentinel enters view, isIntersecting becomes true, the effect fires fetchPosts(page), which sets loading = true.
While loading, the user scrolls back up and then back down. The sentinel re-enters view, the effect fires again, but fetchPosts returns early because loading is still true. So far, so good. But what happens when the request completes and loading flips to false? The useEffect dependency array includes loading, so the effect re-runs.
At that moment, if isIntersecting is still true (the sentinel is still visible), fetchPosts fires automatically for the next page. This is the correct behaviour for users who scroll to the very bottom and wait but it means the effect is doing double duty as both a scroll-triggered loader and a "continue loading if still at bottom" mechanism.
💡 Key Code Explained
function useIntersectionObserver(options) {
const [ref, setRef] = useState(null);
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
if (!ref) return;
const observer = new IntersectionObserver(([entry]) => {
setIsIntersecting(entry.isIntersecting);
}, options);
observer.observe(ref);
return () => observer.disconnect();
}, [ref, options]);
return [setRef, isIntersecting];
}
The useState(null) for ref is what makes the callback ref pattern work reactively. When the sentinel mounts, React calls setRef(element), which updates state. The useEffect lists ref as a dependency, so it re-runs when the element is available, creates the observer, and starts watching.
When the sentinel unmounts (because hasMore becomes false and it is no longer rendered), React calls setRef(null), the effect re-runs, finds !ref, and skips observer creation clean teardown.
The options object is also a dependency, which means if you pass an object literal { threshold: 0.1, rootMargin: '100px' } directly to the hook call, a new object is created on every render, the effect re-runs continuously, and the observer is torn down and recreated every render. The demo calls the hook once at the top level where options is stable across renders.
const fetchPosts = useCallback(
async (pageNum = 1, isRefresh = false) => {
if (loading) return;
setLoading(true);
setError(null);
try {
const result = await mockAPI.fetchPosts(pageNum, 10);
if (isRefresh) {
setPosts(result.data);
} else {
setPosts((prev) => [...prev, ...result.data]);
}
setHasMore(result.hasMore);
setPage(result.nextPage);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
},
[loading],
);
The isRefresh flag is what distinguishes an initial load or manual refresh from a pagination append. Without it, calling fetchPosts(1, true) after reset would append 10 fresh items to an already-cleared posts array which is correct but structurally it is fragile because it relies on the setPosts([]) call from handleRefresh having already committed before fetchPosts reads posts.
Using if (isRefresh) { setPosts(result.data) } instead of setPosts(prev => [...prev, ...result.data]) makes the intent explicit and safe regardless of when the previous clear happened.
The functional form setPosts(prev => [...prev, ...result.data]) for pagination is critical: if two append operations are in flight, each one must close over the latest prev rather than a stale snapshot of posts.
⚖️ Tradeoffs
| Approach | Pro | Con |
|---|---|---|
| IntersectionObserver on sentinel (chosen) | Off-main-thread; fires infrequently; works for both window and container scroll | Sentinel element must exist in the DOM; not supported in IE11 without polyfill |
| Window scroll event listener | Universal browser support; no DOM sentinel needed | Fires constantly at scroll rate; must throttle or debounce; couples component to window |
| Polling REST at intervals | Works even if user is not scrolling; no scroll logic | Loads data the user may never see; increases server load; not responsive to scroll intent |
| Cursor-based pagination (server-side) | Stable results for real-time data; no duplicate items if new items are inserted | Server API must support cursors; cursor state must be preserved through refresh |
| Page number-based pagination (demo) | Simple server implementation | Items can shift between pages when new data is inserted during a session, causing duplicates or gaps |
🎯 What Interviewers Actually Check
- Knows that
optionspassed touseIntersectionObservermust be stable (memoised or defined outside the component) to prevent the observer being torn down and recreated on every render - Can explain that
rootMargin: '100px'pre-triggers loading before the user reaches the bottom, and knows that increasing this value trades earlier loading for potentially wasted API calls - Understands that the
loadingguard prevents parallel requests but the effect dependency onloadingmeans the hook automatically continues loading if the sentinel is still visible after a page completes - Mentions
AbortControllerfor cancelling in-flight requests when the component unmounts, even though the demo does not implement it - Raises cursor-based vs offset-based pagination as a real concern for feeds with frequently-inserted content
❓ Follow-Up Questions
- The
fetchPostsfunction is wrapped inuseCallback([loading]). This means every timeloadingchanges,fetchPostsgets a new reference, which invalidates the effect that calls it. Trace through a full first-load cycle and identify how many timesfetchPostschanges reference and how many times the intersection effect re-runs. - The demo uses page-number pagination. If 5 new posts are inserted at the top of the feed between the user loading page 1 and page 2, what content does page 2 return and does the user see any duplicate or missing posts? How does cursor-based pagination solve this?
- The
mockAPIhas a 5% random failure rate. After a failed request,loadingis set tofalseanderroris set. The user scrolls and the sentinel enters view again. Does it automatically retry? Should it? - How would you add
AbortControllersupport tofetchPostsso that if the component unmounts mid-fetch, the state updates infinallydo not run on an unmounted component? - Your designer asks you to add a skeleton loading state that shows placeholder cards while the next page is loading, replacing the spinner. How does this change the relationship between the
loadingstate and the rendered list?
🎮 Live Demo
📝 Summary
Infinite scrolling is deceptively simple to sketch but full of edge cases in practice. The IntersectionObserver sentinel approach is the correct default because it offloads scroll detection to the browser compositor, fires once per threshold crossing rather than hundreds of times per second, and works correctly for both window-level and container-level scrolling without manual throttling.
The callback ref pattern in useIntersectionObserver is not an aesthetic choice it is what makes the observer reactive to the sentinel's mounting lifecycle. The loading guard is the primary race condition defence, but it doubles as a "continue loading while visible" mechanism when combined with loading as an effect dependency.
The manual "Load more" button is not a fallback for old browsers; it is an accessibility and resilience requirement, covering users who have motion-sensitivity settings that disable auto-scroll behaviours, or situations where the sentinel fails to intersect due to aggressive browser zoom levels.
In a production feed with real-time inserts, cursor-based pagination replaces page-number pagination to eliminate the duplicate and gap problems that arise when new content shifts item positions between pages.
Should I use window scroll or a container scroll?
Prefer container scroll or IntersectionObserver on a sentinel. Window scrolling works but is harder to isolate for components and tests.
When to use virtualization with infinite scroll?
When the list could grow large (thousands of items) or each item is heavy. Virtualization prevents DOM bloat.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement