Loading challenge...
Preparing the challenge details...
Loading challenge...
Preparing the challenge details...
Implement infinite scrolling in React using Intersection Observer API. Learn lazy loading, pagination, scroll position restoration, and performance optimization techniques.
Build an infinite scrolling component that automatically loads more data as users scroll down. The key challenges are detecting when the user reaches the bottom, preventing duplicate API calls, and providing smooth loading states.
Infinite scrolling (also called "endless scroll" or "virtual scrolling") automatically loads more content when the user scrolls near the bottom of a container. Instead of traditional pagination with "Next" buttons, content loads seamlessly as the user scrolls, creating a continuous browsing experience.
The implementation uses a three-layer architecture:
┌─────────────────────────────────────┐
│ InfiniteScrollingDemo (Parent) │
│ - Manages data state │
│ - Handles API calls │
│ - Controls loading states │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ InfiniteScrollingComponent │
│ - Renders children │
│ - Shows loader/end message │
│ - Places sentinel element │
└──────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────┐
│ useInfiniteScroll Hook │
│ - Intersection Observer setup │
│ - Triggers onLoadMore callback │
│ - Prevents duplicate calls │
└─────────────────────────────────────┘
Key Components:
The main challenge is knowing when to load more data. We need to detect when the user is near the bottom without constantly checking scroll position (which is expensive).
TSXcomponent.tsx1// BAD: Expensive and inefficient 2useEffect(() => { 3 const handleScroll = () => { 4 const { scrollTop, scrollHeight, clientHeight } = containerRef.current; 5 if (scrollTop + clientHeight >= scrollHeight - 100) { 6 loadMore(); 7 } 8 }; 9 container.addEventListener('scroll', handleScroll); 10}, []);
Issues:
The Intersection Observer API watches when an element enters or exits the viewport (or a specified root element). It's:
The Intersection Observer is a browser API that asynchronously observes changes in the intersection of a target element with an ancestor element or the viewport.
TSXcomponent.tsx1const observer = new IntersectionObserver((entries) => { 2 entries.forEach(entry => { 3 if (entry.isIntersecting) { 4 // Element is visible 5 onLoadMore(); 6 } 7 }); 8}, { 9 root: null, // Viewport (default) 10 rootMargin: '200px', // Trigger 200px before element is visible 11 threshold: 0 // Trigger when any part is visible 12}); 13 14observer.observe(sentinelElement);
root: The element used as the viewport for checking visibility
null = browser viewportdocument.getElementById('container') = specific containerrootMargin: Margin around the root (like CSS margin)
'200px' = trigger 200px before element enters viewport'0px' = trigger exactly when element enters'-100px' = trigger 100px after element entersthreshold: Percentage of element that must be visible
0 = trigger when any pixel is visible0.5 = trigger when 50% is visible1 = trigger when 100% is visiblerootMargin: '200px'?Setting rootMargin: '200px' means the observer triggers 200px before the sentinel element actually becomes visible. This:
┌─────────────────────────────┐
│ Visible Content │
│ │
│ ┌─────────────────────┐ │
│ │ Item 1 │ │
│ │ Item 2 │ │
│ │ Item 3 │ │
│ │ ... │ │
│ └─────────────────────┘ │
│ ──────────────────────── │ ← 200px margin
│ ──────────────────────── │
│ [Sentinel Element] │ ← Triggers here
│ ──────────────────────── │
│ [Bottom of Container] │
└─────────────────────────────┘
The useInfiniteScroll hook encapsulates all Intersection Observer logic:
TSXcomponent.tsx1const useInfiniteScrolling = ({ 2 onLoadMore, 3 hasMore, 4 root = null, 5 rootMargin = "200px", 6 disabled = false 7}: UseInfiniteScrollingProps) => { 8 const observerRef = useRef<HTMLDivElement | null>(null); 9 const loadingRef = useRef(false); 10 11 useEffect(() => { 12 if (disabled || !hasMore) return; 13 if (!observerRef.current) return; 14 15 const observer = new IntersectionObserver((entries) => { 16 const [entry] = entries; 17 if (entry.isIntersecting && !loadingRef.current && hasMore) { 18 loadingRef.current = true; 19 onLoadMore(); 20 // Reset loading flag after a short delay 21 setTimeout(() => { 22 loadingRef.current = false; 23 }, 100); 24 } 25 }, { 26 root, 27 rootMargin 28 }) 29 30 observer.observe(observerRef.current); 31 return () => { 32 observer.disconnect(); 33 } 34 }, [onLoadMore, hasMore, root, rootMargin, disabled]) 35 36 return { observerRef }; 37}
1. Loading Guard (loadingRef)
TSXcomponent.tsx1const loadingRef = useRef(false);
Prevents multiple simultaneous API calls:
true when onLoadMore is called2. Conditional Observation
TSXcomponent.tsx1if (disabled || !hasMore) return;
Stops observing when:
hasMore = false)3. Cleanup
TSXcomponent.tsx1return () => { 2 observer.disconnect(); 3}
Properly disconnects the observer when:
The container component manages rendering and provides the sentinel element:
TSXcomponent.tsx1const InfiniteScrollingComponent = ({ 2 children, 3 onLoadMore, 4 hasMore, 5 isLoading, 6 loader = null, 7 endMessage = null 8}: InfiniteScrollingComponentProps) => { 9 const { observerRef } = useInfiniteScrolling({ 10 onLoadMore, 11 hasMore: hasMore && !isLoading // Disable when loading 12 }); 13 14 return ( 15 <div className="infinite-scrolling-component"> 16 {children} 17 {isLoading && loader} 18 {hasMore ? ( 19 <div ref={observerRef} className="infinite-scroll-sentinel" /> 20 ) : ( 21 endMessage 22 )} 23 </div> 24 ) 25}
Responsibilities:
isLoading is truehasMore is trueWhy hasMore && !isLoading?
Disabling the observer during loading prevents:
The parent component manages all state and data fetching:
TSXcomponent.tsx1const InfiniteScrollingDemo = () => { 2 const [data, setData] = useState<DataItem[]>([]); 3 const [loading, setLoading] = useState(false); 4 const [hasMore, setHasMore] = useState(true); 5 const [page, setPage] = useState(1); 6 const maxItems = 100; 7 8 const loadMoreData = useCallback(async () => { 9 if (loading || !hasMore) return; 10 11 setLoading(true); 12 try { 13 const newData = await fetchMoreData(page); 14 15 if (data.length + newData.length >= maxItems) { 16 setHasMore(false); 17 } 18 19 setData(prev => [...prev, ...newData]); 20 setPage(prev => prev + 1); 21 } catch (error) { 22 console.error('Error loading more data:', error); 23 } finally { 24 setLoading(false); 25 } 26 }, [data.length, loading, hasMore, page]); 27 28 // Load initial data 29 React.useEffect(() => { 30 loadMoreData(); 31 }, []); 32 33 return ( 34 <div className="infinite-scrolling-demo"> 35 <InfiniteScrollingComponent 36 hasMore={hasMore} 37 isLoading={loading} 38 onLoadMore={loadMoreData} 39 loader={<Skeleton />} 40 endMessage={<EmptyState />} 41 > 42 {data.map(item => ( 43 <div key={item.id} className="box"> 44 {item.content} 45 </div> 46 ))} 47 </InfiniteScrollingComponent> 48 </div> 49 ) 50}
State Management:
data: Array of loaded itemsloading: Whether an API call is in progresshasMore: Whether more data is availablepage: Current page number for paginationWhy useCallback?
Wrapping loadMoreData in useCallback prevents:
The demo uses a mock API function to simulate real data fetching:
TSXcomponent.tsx1const fetchMoreData = async ( 2 page: number, 3 pageSize: number = 10 4): Promise<DataItem[]> => { 5 // Simulate API delay 6 await new Promise(resolve => setTimeout(resolve, 1000)); 7 8 const startId = (page - 1) * pageSize + 1; 9 return Array.from({ length: pageSize }, (_, index) => ({ 10 id: startId + index, 11 content: `Item ${startId + index}` 12 })); 13}
How it works:
setTimeout simulates network latency (1 second)Real-world usage: Replace with actual API call:
TSXcomponent.tsx1const fetchMoreData = async (page: number) => { 2 const response = await fetch(`/api/items?page=${page}&limit=10`); 3 return response.json(); 4}
Shows a placeholder while data is loading:
TSXcomponent.tsx1const Skeleton = () => ( 2 <div className="box skeleton"> 3 <div className="skeleton-content">Loading...</div> 4 </div> 5);
CSS Animation:
CSSstyles.css1.skeleton-content { 2 background: linear-gradient( 3 90deg, 4 #2d3748 0%, 5 #4a5568 50%, 6 #2d3748 100% 7 ); 8 background-size: 200% 100%; 9 animation: loading 1.5s ease-in-out infinite; 10} 11 12@keyframes loading { 13 0% { background-position: 200% 0; } 14 100% { background-position: -200% 0; } 15}
Creates a shimmer effect that indicates loading without showing actual content.
Shows when all data has been loaded:
TSXcomponent.tsx1const EmptyState = () => ( 2 <div className="empty-state"> 3 <p>No more items to load</p> 4 </div> 5);
The sentinel is an invisible element placed at the bottom of the content:
TSXcomponent.tsx1{hasMore ? ( 2 <div ref={observerRef} className="infinite-scroll-sentinel" /> 3) : ( 4 endMessage 5)}
CSS:
CSSstyles.css1.infinite-scroll-sentinel { 2 height: 1px; 3 width: 100%; 4}
Why invisible?
Multiple mechanisms prevent duplicate API calls:
TSXcomponent.tsx1if (entry.isIntersecting && !loadingRef.current && hasMore) { 2 loadingRef.current = true; 3 onLoadMore(); 4}
TSXcomponent.tsx1const loadMoreData = useCallback(async () => { 2 if (loading || !hasMore) return; // Early return 3 // ... fetch data 4}, [loading, hasMore, ...]);
TSXcomponent.tsx1const { observerRef } = useInfiniteScrolling({ 2 onLoadMore, 3 hasMore: hasMore && !isLoading // Disabled when loading 4});
Defense in depth: Multiple layers ensure no duplicate calls even if one mechanism fails.
The container must have:
overflow-y: auto (or scroll)CSSstyles.css1.infinite-scrolling-demo { 2 height: 600px; 3 overflow-y: auto; 4 /* ... */ 5}
Why fixed height?
For viewport scrolling: If you want to scroll the entire page instead:
TSXcomponent.tsx1const { observerRef } = useInfiniteScrolling({ 2 onLoadMore, 3 hasMore, 4 root: null // Uses viewport 5});
Always handle errors in data fetching:
TSXcomponent.tsx1const loadMoreData = useCallback(async () => { 2 if (loading || !hasMore) return; 3 4 setLoading(true); 5 try { 6 const newData = await fetchMoreData(page); 7 setData(prev => [...prev, ...newData]); 8 setPage(prev => prev + 1); 9 } catch (error) { 10 console.error('Error loading more data:', error); 11 // Optionally: show error message to user 12 // setError('Failed to load more items'); 13 } finally { 14 setLoading(false); 15 } 16}, []);
Why finally?
Ensures loading is always reset, even if an error occurs.
TSXcomponent.tsx1const loadMoreData = useCallback(async () => { 2 // ... 3}, [data.length, loading, hasMore, page]);
Prevents unnecessary re-renders and observer re-initialization.
TSXcomponent.tsx1setData(prev => [...prev, ...newData]);
Uses functional update to avoid dependency on data in callback.
TSXcomponent.tsx1useEffect(() => { 2 // ... setup observer 3 return () => { 4 observer.disconnect(); 5 } 6}, [dependencies]);
Prevents memory leaks by disconnecting observers.
For very large lists (thousands of items), consider virtual scrolling:
react-window, react-virtualizedIf API calls are expensive, debounce the onLoadMore callback:
TSXcomponent.tsx1const debouncedLoadMore = useMemo( 2 () => debounce(loadMoreData, 300), 3 [loadMoreData] 4);
Cache loaded pages to avoid re-fetching:
TSXcomponent.tsx1const [cache, setCache] = useState<Record<number, DataItem[]>>({}); 2 3const loadMoreData = useCallback(async () => { 4 if (cache[page]) { 5 setData(prev => [...prev, ...cache[page]]); 6 return; 7 } 8 // ... fetch and cache 9}, [page, cache]);
loading, hasMore, and data separatelyThe beauty of this approach is its simplicity and efficiency. The Intersection Observer API handles all the heavy lifting, and the component architecture keeps concerns separated. Whether loading 10 items or 10,000, the same pattern works perfectly.
Goal: Implement an infinite scrolling list that fetches and renders more items as the user scrolls.
Continue learning with these related challenges

Create an interactive image carousel in React with smooth slide transitions, navigation arrows, dot indicators, autoplay, and touch/swipe support for mobile devices.

Build a dynamic Tic Tac Toe game in React with customizable grid sizes, win detection algorithms, player turn management, and game reset functionality. Great for interviews.

Create an animated dice roller component in React with realistic rolling animations, random number generation, multiple dice support, and roll history tracking.

Create an interactive image carousel in React with smooth slide transitions, navigation arrows, dot indicators, autoplay, and touch/swipe support for mobile devices.

Build a dynamic Tic Tac Toe game in React with customizable grid sizes, win detection algorithms, player turn management, and game reset functionality. Great for interviews.

Create an animated dice roller component in React with realistic rolling animations, random number generation, multiple dice support, and roll history tracking.