How would you implement infinite scrolling in React?

Advanced20 min interview
Skills tested:
IntersectionObserver APIInfinite Pagination ArchitectureRace Condition PreventionReact Hooks and EffectsVirtualization StrategiesAsync Data Fetching

Advertisement

🧩 Scenario

You need to build an infinite-scrolling list component used across the app (feeds, search results). Requirements: - Load next page when the user scrolls near the bottom - Show loading state and error handling - Avoid duplicate requests and race conditions - Support refreshing and manual "Load more" - Be performant for long lists (optionally support virtualization) Implement a production-ready example and explain tradeoffs.

🧠 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

ApproachProCon
IntersectionObserver on sentinel (chosen)Off-main-thread; fires infrequently; works for both window and container scrollSentinel element must exist in the DOM; not supported in IE11 without polyfill
Window scroll event listenerUniversal browser support; no DOM sentinel neededFires constantly at scroll rate; must throttle or debounce; couples component to window
Polling REST at intervalsWorks even if user is not scrolling; no scroll logicLoads 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 insertedServer API must support cursors; cursor state must be preserved through refresh
Page number-based pagination (demo)Simple server implementationItems can shift between pages when new data is inserted during a session, causing duplicates or gaps

🎯 What Interviewers Actually Check

  • Knows that options passed to useIntersectionObserver must 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 loading guard prevents parallel requests but the effect dependency on loading means the hook automatically continues loading if the sentinel is still visible after a page completes
  • Mentions AbortController for 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

  1. The fetchPosts function is wrapped in useCallback([loading]). This means every time loading changes, fetchPosts gets a new reference, which invalidates the effect that calls it. Trace through a full first-load cycle and identify how many times fetchPosts changes reference and how many times the intersection effect re-runs.
  2. 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?
  3. The mockAPI has a 5% random failure rate. After a failed request, loading is set to false and error is set. The user scrolls and the sentinel enters view again. Does it automatically retry? Should it?
  4. How would you add AbortController support to fetchPosts so that if the component unmounts mid-fetch, the state updates in finally do not run on an unmounted component?
  5. 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 loading state 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.

Frequently Asked Questions

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