#Infinite Scroll#React

Infinite Scrolling

Implement infinite scrolling in React using Intersection Observer API. Learn lazy loading, pagination, scroll position restoration, and performance optimization techniques.

By Pratik RaiMedium

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.

The task

Goal: load and render a list that grows as the user scrolls.

Requirements

  1. Load an initial page of items and render them.
  2. Load the next page automatically as the user approaches the bottom.
  3. Show a loading indicator while a page is in flight.
  4. Stop requesting once there are no more items.
  5. Never fire two requests for the same page.

Stretch goals

  • Handle a failed request with a retry.
  • Preserve scroll position when returning to the list.
  • Virtualise the list so the DOM does not grow without limit.

Hints

  1. Prefer an Intersection Observer over a scroll listener. Watching a sentinel element at the end of the list avoids running a handler on every scroll frame.
  2. Guard on a loading flag before firing. Without it, the observer fires repeatedly while the sentinel stays on screen and you request the same page several times.
  3. Keep the page number and the loading flag in state together, so the effect that fetches has one source of truth to read.
  4. The initial load should happen once, not once per render — and remember that effects run twice in development, so a naive guard hides the bug rather than fixing it.

Overview

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.

Architecture Overview

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:

  1. Sentinel Element: An invisible div at the bottom that triggers loading when it becomes visible
  2. Intersection Observer: Browser API that watches when the sentinel enters the viewport
  3. Loading Guard: Prevents multiple simultaneous API calls

Core Challenge: Detecting Scroll Position

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).

The Problem with Scroll Events

TSXcomponent.tsx
1// 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:

  • Fires hundreds of times per second during scrolling
  • Requires manual calculations
  • Can cause performance issues
  • Hard to optimize

The Solution: Intersection Observer API

The Intersection Observer API watches when an element enters or exits the viewport (or a specified root element). It's:

  • Efficient: Only fires when intersection changes, not continuously
  • Native: Built into browsers, no manual calculations
  • Flexible: Can observe multiple elements with one observer
  • Performant: Uses browser optimizations

Intersection Observer API

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.

Basic Setup

TSXcomponent.tsx
1const 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);

Configuration Options

root: The element used as the viewport for checking visibility

  • null = browser viewport
  • document.getElementById('container') = specific container

rootMargin: 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 enters

threshold: Percentage of element that must be visible

  • 0 = trigger when any pixel is visible
  • 0.5 = trigger when 50% is visible
  • 1 = trigger when 100% is visible

Why rootMargin: '200px'?

Setting rootMargin: '200px' means the observer triggers 200px before the sentinel element actually becomes visible. This:

  • Loads data before the user reaches the bottom
  • Creates a seamless experience (no waiting)
  • Hides loading time from the user
┌─────────────────────────────┐
│  Visible Content            │
│                             │
│  ┌─────────────────────┐   │
│  │  Item 1             │   │
│  │  Item 2             │   │
│  │  Item 3             │   │
│  │  ...                │   │
│  └─────────────────────┘   │
│  ────────────────────────  │ ← 200px margin
│  ────────────────────────  │
│  [Sentinel Element]        │ ← Triggers here
│  ────────────────────────  │
│  [Bottom of Container]     │
└─────────────────────────────┘

Implementation: Custom Hook

The useInfiniteScroll hook encapsulates all Intersection Observer logic:

TSXcomponent.tsx
1const 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}

Key Features

1. Loading Guard (loadingRef)

TSXcomponent.tsx
1const loadingRef = useRef(false);

Prevents multiple simultaneous API calls:

  • Set to true when onLoadMore is called
  • Reset after 100ms delay
  • Prevents rapid-fire triggers during fast scrolling

2. Conditional Observation

TSXcomponent.tsx
1if (disabled || !hasMore) return;

Stops observing when:

  • Hook is disabled
  • No more data available (hasMore = false)

3. Cleanup

TSXcomponent.tsx
1return () => { 2 observer.disconnect(); 3}

Properly disconnects the observer when:

  • Component unmounts
  • Dependencies change
  • Prevents memory leaks

Component Architecture

1. InfiniteScrollingComponent (Container)

The container component manages rendering and provides the sentinel element:

TSXcomponent.tsx
1const 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:

  • Renders children (the actual content items)
  • Shows loading skeleton when isLoading is true
  • Places sentinel element when hasMore is true
  • Shows end message when all data is loaded

Why hasMore && !isLoading? Disabling the observer during loading prevents:

  • Multiple triggers while data is being fetched
  • Race conditions
  • Duplicate API calls

2. InfiniteScrollingDemo (Parent)

The parent component manages all state and data fetching:

TSXcomponent.tsx
1const 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 items
  • loading: Whether an API call is in progress
  • hasMore: Whether more data is available
  • page: Current page number for pagination

Why useCallback? Wrapping loadMoreData in useCallback prevents:

  • Recreating the function on every render
  • Causing the Intersection Observer to re-initialize
  • Unnecessary effect re-runs

Mock API Implementation

The demo uses a mock API function to simulate real data fetching:

TSXcomponent.tsx
1const 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:

  1. Delay: setTimeout simulates network latency (1 second)
  2. Pagination: Calculates starting ID based on page number
  3. Page Size: Returns 10 items per page (configurable)

Real-world usage: Replace with actual API call:

TSXcomponent.tsx
1const fetchMoreData = async (page: number) => { 2 const response = await fetch(`/api/items?page=${page}&limit=10`); 3 return response.json(); 4}

Loading States

Skeleton Loader

Shows a placeholder while data is loading:

TSXcomponent.tsx
1const Skeleton = () => ( 2 <div className="box skeleton"> 3 <div className="skeleton-content">Loading...</div> 4 </div> 5);

CSS Animation:

CSSstyles.css
1.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.

Empty State

Shows when all data has been loaded:

TSXcomponent.tsx
1const EmptyState = () => ( 2 <div className="empty-state"> 3 <p>No more items to load</p> 4 </div> 5);

Sentinel Element

The sentinel is an invisible element placed at the bottom of the content:

TSXcomponent.tsx
1{hasMore ? ( 2 <div ref={observerRef} className="infinite-scroll-sentinel" /> 3) : ( 4 endMessage 5)}

CSS:

CSSstyles.css
1.infinite-scroll-sentinel { 2 height: 1px; 3 width: 100%; 4}

Why invisible?

  • Doesn't affect layout
  • Doesn't distract users
  • Only serves as a trigger point for the observer

Preventing Duplicate Calls

Multiple mechanisms prevent duplicate API calls:

1. Loading Guard in Hook

TSXcomponent.tsx
1if (entry.isIntersecting && !loadingRef.current && hasMore) { 2 loadingRef.current = true; 3 onLoadMore(); 4}

2. Guard in Parent Component

TSXcomponent.tsx
1const loadMoreData = useCallback(async () => { 2 if (loading || !hasMore) return; // Early return 3 // ... fetch data 4}, [loading, hasMore, ...]);

3. Disable Observer During Loading

TSXcomponent.tsx
1const { 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.

Scroll Container Setup

The container must have:

  • Fixed height
  • overflow-y: auto (or scroll)
CSSstyles.css
1.infinite-scrolling-demo { 2 height: 600px; 3 overflow-y: auto; 4 /* ... */ 5}

Why fixed height?

  • Scroll container must be defined for Intersection Observer
  • Without a scroll container, observer uses viewport (not what we want)
  • Enables scrolling within the component

For viewport scrolling: If you want to scroll the entire page instead:

TSXcomponent.tsx
1const { observerRef } = useInfiniteScrolling({ 2 onLoadMore, 3 hasMore, 4 root: null // Uses viewport 5});

Error Handling

Always handle errors in data fetching:

TSXcomponent.tsx
1const 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.

Performance Considerations

1. Memoization

TSXcomponent.tsx
1const loadMoreData = useCallback(async () => { 2 // ... 3}, [data.length, loading, hasMore, page]);

Prevents unnecessary re-renders and observer re-initialization.

2. Efficient Updates

TSXcomponent.tsx
1setData(prev => [...prev, ...newData]);

Uses functional update to avoid dependency on data in callback.

3. Cleanup

TSXcomponent.tsx
1useEffect(() => { 2 // ... setup observer 3 return () => { 4 observer.disconnect(); 5 } 6}, [dependencies]);

Prevents memory leaks by disconnecting observers.

Advanced Patterns

Virtual Scrolling

For very large lists (thousands of items), consider virtual scrolling:

Debouncing

If API calls are expensive, debounce the onLoadMore callback:

TSXcomponent.tsx
1const debouncedLoadMore = useMemo( 2 () => debounce(loadMoreData, 300), 3 [loadMoreData] 4);

Caching

Cache loaded pages to avoid re-fetching:

TSXcomponent.tsx
1const [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]);

Key Takeaways

  1. Intersection Observer: Use instead of scroll events for better performance
  2. Sentinel Element: Invisible element at bottom triggers loading
  3. rootMargin: Trigger loading before user reaches bottom (better UX)
  4. Loading Guards: Multiple layers prevent duplicate API calls
  5. useCallback: Memoize callbacks to prevent unnecessary re-renders
  6. Error Handling: Always handle API errors gracefully
  7. Cleanup: Disconnect observers to prevent memory leaks
  8. State Management: Track loading, hasMore, and data separately

The 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.

What interviewers look for

  • Scroll listener or Intersection Observer? Both work; only one of them explains why the other is expensive.
  • Can it double-fire? This is the bug interviewers look for hardest, because it is invisible until you watch the network tab and then obvious.
  • Does it know when to stop? Requesting page 47 of a 3-page list forever is a common oversight once the happy path works.
  • What happens when a request fails? Silently leaving a spinner on screen is the default, and saying what you would do instead costs nothing.

Goal: Implement an infinite scrolling list that fetches and renders more items as the user scrolls.

Frequently asked questions

How do you implement infinite scroll in React?
Render a small sentinel element after the list and watch it with an Intersection Observer. When the sentinel comes into view, load the next page. This avoids attaching a handler that runs on every scroll frame, which is the older approach and the one interviewers expect you to be able to compare against.
Why use Intersection Observer instead of a scroll listener?
A scroll listener fires continuously and forces you to read layout properties to work out where you are, which is expensive during the exact interaction that needs to stay smooth. The observer does that work off the main thread and calls you only when the element you care about crosses the threshold.
What is the most common bug in infinite scroll?
Firing the same request several times. The sentinel stays on screen while the request is in flight, so the observer keeps reporting it as visible and the same page is fetched repeatedly. A loading flag checked before dispatching is what prevents it.
How do you know when to stop loading more?
The API has to tell you — either a total count, an explicit flag, or a short final page. Without that signal the component keeps asking for pages that do not exist, which is easy to miss because nothing visibly breaks.

Related Challenges

Continue learning with these related challenges

View All
React

Image Carousel

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

React · JavaScriptPratik Rai ·

React

Dynamic Tic Tac Toe

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.

React · JavaScriptPratik Rai ·

React

Dice Roller

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

React · JavaScriptPratik Rai ·