React Curriculum

Machine Coding: Infinite Scroll with IntersectionObserver

Medium100% Free~30 mins#react#machine-coding#infinite-scroll#intersection-observer#pagination
Interview Answer (30-Second Summary)

Production infinite scroll uses the IntersectionObserver API attached to a sentinel element at the bottom of the list. When the sentinel enters the viewport, it triggers the next page fetch while managing loading flags, error retries, and disconnecting the observer on unmount.

Key Learning Objectives
✓

Implement Infinite Scroll using the browser IntersectionObserver API.

✓

Explain why IntersectionObserver is vastly superior to window.onscroll event listeners.

✓

Manage pagination state: page number, hasMore flag, and loading deduplication.

✓

Implement clean observer disconnection in useEffect cleanup.

The Interview Problem & Context

Build a production `<InfiniteList fetchItems={async (page) => ...} />` component using `IntersectionObserver` that loads subsequent pages when scrolling near the bottom.

1import React, { useState, useEffect, useRef, useCallback } from 'react';
2
3interface InfiniteScrollProps<T> {
4 fetchPage: (page: number) => Promise<{ items: T[]; hasMore: boolean }>;
5 renderItem: (item: T, index: number) => React.ReactNode;
6}
7
8export function InfiniteScroll<T>({ fetchPage, renderItem }: InfiniteScrollProps<T>) {
9 const [items, setItems] = useState<T[]>([]);
10 const [page, setPage] = useState(1);
11 const [loading, setLoading] = useState(false);
12 const [hasMore, setHasMore] = useState(true);
13
14 // Sentinel DOM element observed at the end of the list
15 const sentinelRef = useRef<HTMLDivElement>(null);
16
17 const loadMore = useCallback(async () => {
18 if (loading || !hasMore) return;
19
20 setLoading(true);
21 try {
22 const response = await fetchPage(page);
23 setItems((prev) => [...prev, ...response.items]);
24 setHasMore(response.hasMore);
25 setPage((prev) => prev + 1);
26 } catch (err) {
27 console.error('Failed to load next page:', err);
28 } finally {
29 setLoading(false);
30 }
31 }, [page, loading, hasMore, fetchPage]);
32
33 // Attach IntersectionObserver to sentinel
34 useEffect(() => {
35 const sentinel = sentinelRef.current;
36 if (!sentinel || !hasMore) return;
37
38 const observer = new IntersectionObserver(
39 (entries) => {
40 const firstEntry = entries[0];
41 // When sentinel scrolls into viewport and not currently loading, fetch next page
42 if (firstEntry.isIntersecting && !loading) {
43 loadMore();
44 }
45 },
46 { rootMargin: '200px' } // Pre-fetch 200px before user actually reaches bottom!
47 );
48
49 observer.observe(sentinel);
50
51 return () => {
52 observer.disconnect(); // Prevent memory leaks
53 };
54 }, [loadMore, hasMore, loading]);
55
56 return (
57 <div className="infinite-scroll-container">
58 <div className="items-list">
59 {items.map((item, index) => renderItem(item, index))}
60 </div>
61
62 {/* Sentinel element placed at bottom of list */}
63 <div ref={sentinelRef} className="sentinel" style={{ height: '20px' }}>
64 {loading && <p>Loading more items...</p>}
65 {!hasMore && items.length > 0 && <p>You have reached the end of the feed.</p>}
66 </div>
67 </div>
68 );
69}
Deep Architectural Explanation

Why IntersectionObserver Outperforms Scroll Listeners

The Legacy onScroll Anti-pattern:

  • window.addEventListener("scroll", handleScroll) fires hundreds of times per second during scrolling.
  • Calling element.getBoundingClientRect() inside the scroll handler forces the browser to perform synchronous layout recalculation (Layout Thrashing).
  • Causes visible frame drops and battery drain on mobile devices.

The Modern IntersectionObserver Pattern:

  1. Offloaded to Browser Engine: The browser monitors element intersections asynchronously off the main thread.
  2. Sentinel Node: A tiny invisible <div> placed at the bottom of the list.
  3. rootMargin Optimization: Setting rootMargin: "200px" triggers the fetch 200 pixels before the user actually reaches the bottom! The next page is loaded and rendered seamlessly before the user ever sees a loading spinner.
  4. Deduplication: Checking !loading && hasMore ensures duplicate requests are never dispatched.
Common Interview Traps & Pitfalls
✕

Using window.onscroll without throttling or debouncing, causing extreme layout thrashing.

✕

Forgetting to disconnect the IntersectionObserver in the effect cleanup.

✕

Allowing multiple concurrent fetches by not checking the loading state flag before dispatching requests.

Senior Interviewer Follow-Up Probes
?

How does TanStack Query`s `useInfiniteQuery` simplify pagination state and caching?

?

How do you handle bidirectional infinite scrolling (loading up AND down, e.g. in chat history)?

?

How do you preserve scroll position when a user navigates away and clicks browser back?

Frequently Asked Interview Questions
Why is IntersectionObserver preferred over window scroll events?

IntersectionObserver runs asynchronously without blocking the browser main thread and eliminates layout thrashing caused by repeatedly querying DOM dimensions during scrolling.

It expands the bounding box of the viewport used for intersection detection, allowing data prefetching before the sentinel actually appears on screen.

Check boolean flags (!loading && hasMore) before initiating a request, and set loading to true synchronously before awaiting the async response.

DOM nodes accumulate infinitely, increasing memory usage and slowing down rendering. Combine infinite scroll with virtualization to recycle offscreen DOM nodes.

If a fetch fails, set error state and display a retry button inside the sentinel container, allowing users to re-trigger the failed page fetch manually.

To prevent memory leaks and stop the observer from firing callbacks on unmounted DOM elements after the component leaves the screen.