- React
- Live Machine Coding Challenges and Custom Hooks
- Machine Coding: Infinite Scroll with IntersectionObserver
Machine Coding: Infinite Scroll with IntersectionObserver
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';23interface InfiniteScrollProps<T> {4 fetchPage: (page: number) => Promise<{ items: T[]; hasMore: boolean }>;5 renderItem: (item: T, index: number) => React.ReactNode;6}78export 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);1314 // Sentinel DOM element observed at the end of the list15 const sentinelRef = useRef<HTMLDivElement>(null);1617 const loadMore = useCallback(async () => {18 if (loading || !hasMore) return;1920 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]);3233 // Attach IntersectionObserver to sentinel34 useEffect(() => {35 const sentinel = sentinelRef.current;36 if (!sentinel || !hasMore) return;3738 const observer = new IntersectionObserver(39 (entries) => {40 const firstEntry = entries[0];41 // When sentinel scrolls into viewport and not currently loading, fetch next page42 if (firstEntry.isIntersecting && !loading) {43 loadMore();44 }45 },46 { rootMargin: '200px' } // Pre-fetch 200px before user actually reaches bottom!47 );4849 observer.observe(sentinel);5051 return () => {52 observer.disconnect(); // Prevent memory leaks53 };54 }, [loadMore, hasMore, loading]);5556 return (57 <div className="infinite-scroll-container">58 <div className="items-list">59 {items.map((item, index) => renderItem(item, index))}60 </div>6162 {/* 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:
- Offloaded to Browser Engine: The browser monitors element intersections asynchronously off the main thread.
- Sentinel Node: A tiny invisible <div> placed at the bottom of the list.
- 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.
- 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
What is the purpose of rootMargin in IntersectionObserver?
It expands the bounding box of the viewport used for intersection detection, allowing data prefetching before the sentinel actually appears on screen.
How do you prevent duplicate fetches during infinite scrolling?
Check boolean flags (!loading && hasMore) before initiating a request, and set loading to true synchronously before awaiting the async response.
What happens to the DOM size in very long infinite scroll feeds?
DOM nodes accumulate infinitely, increasing memory usage and slowing down rendering. Combine infinite scroll with virtualization to recycle offscreen DOM nodes.
How do you handle error states and retries in infinite scrolling?
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.
Why should the observer be disconnected in useEffect cleanup?
To prevent memory leaks and stop the observer from firing callbacks on unmounted DOM elements after the component leaves the screen.
