How to Implement Infinite Scroll in React with Intersection Observer API

How to Implement Infinite Scroll in React with Intersection Observer API

by | Jun 20, 2026 | Uncategorized | 0 comments

Why Infinite Scroll in React Still Matters in 2026

Infinite scroll React implementations are everywhere: social media feeds, product catalogs, search results, dashboards. Users expect content to load seamlessly as they scroll down, without clicking a “Load More” button or navigating between pages.

Many developers reach for third-party packages like react-infinite-scroll-component to solve this. But there is a lighter, more flexible approach that ships zero extra kilobytes to your users: the browser-native Intersection Observer API.

In this hands-on tutorial, you will build a complete infinite scroll feature in React from scratch. We will cover:

  • How the Intersection Observer API works
  • Setting up a React project with a fake API
  • Building the infinite scroll logic step by step
  • Handling loading states and errors gracefully
  • Extracting everything into a reusable custom hook
  • Performance optimizations and best practices

By the end, you will have a production-ready infinite scroll React solution that is lightweight, accessible, and easy to maintain.

Intersection Observer API: A Quick Primer

Before writing any React code, let’s understand the tool we are using.

The Intersection Observer API lets you watch when a target element enters or exits the viewport (or any ancestor element). Instead of attaching a scroll event listener and manually calculating positions, you tell the browser: “Let me know when this element becomes visible.”

Key Concepts

Term Meaning
Observer The instance you create with new IntersectionObserver(callback, options)
Target The DOM element being watched
Root The scrollable ancestor (defaults to the viewport)
rootMargin Margin around the root, useful for triggering early (e.g., 200px before the element is visible)
threshold A number between 0 and 1 indicating how much of the target must be visible to trigger the callback

Why Choose This Over scroll Event Listeners?

  • Performance: Intersection Observer runs off the main thread. Scroll listeners fire on every pixel of movement and can cause jank.
  • Simplicity: No manual math with getBoundingClientRect(), scrollTop, or offsetHeight.
  • Browser support: Supported in all modern browsers since 2019. In 2026, there is no reason to avoid it.
scrolling website list loading

Project Setup

We will use a standard React project. If you are starting fresh, create one with Vite:

npm create vite@latest infinite-scroll-demo -- --template react
cd infinite-scroll-demo
npm install
npm run dev

We will fetch data from the free JSONPlaceholder API. It provides paginated posts, which is perfect for demonstrating infinite scroll in React.

The endpoint we will use:

https://jsonplaceholder.typicode.com/posts?_page=1&_limit=10

This returns 10 posts per page and supports pagination through the _page parameter.

Step 1: Build the Basic Component with State

Create a new file called InfinitePostList.jsx:

import { useState, useEffect, useRef, useCallback } from "react";

const POSTS_PER_PAGE = 10;

export default function InfinitePostList() {
  const [posts, setPosts] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [hasMore, setHasMore] = useState(true);

  const fetchPosts = useCallback(async () => {
    if (loading || !hasMore) return;
    setLoading(true);
    setError(null);

    try {
      const response = await fetch(
        `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${POSTS_PER_PAGE}`
      );

      if (!response.ok) {
        throw new Error(`Server responded with status ${response.status}`);
      }

      const newPosts = await response.json();

      if (newPosts.length === 0) {
        setHasMore(false);
      } else {
        setPosts((prev) => [...prev, ...newPosts]);
        setPage((prev) => prev + 1);
      }
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, [page, loading, hasMore]);

  return (
    <div style={{ maxWidth: 600, margin: "0 auto", padding: 20 }}>
      <h1>Posts</h1>
      {posts.map((post) => (
        <div key={post.id} style={{ borderBottom: "1px solid #ddd", padding: 16 }}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </div>
      ))}
      {loading && <p>Loading more posts...</p>}
      {error && <p style={{ color: "red" }}>Error: {error}</p>}
      {!hasMore && <p>You have reached the end.</p>}
    </div>
  );
}

At this point, the component has all the state management in place, but nothing triggers fetchPosts as the user scrolls. That is where the Intersection Observer comes in.

scrolling website list loading

Step 2: Add the Intersection Observer

The idea is simple: place an invisible “sentinel” element at the bottom of the list. When the observer detects it entering the viewport, we fetch the next page.

Add the following code inside the component, right before the return statement:

  // Ref for the sentinel element
  const sentinelRef = useRef(null);

  // Load the first page on mount
  useEffect(() => {
    fetchPosts();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  // Set up the Intersection Observer
  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

    const observer = new IntersectionObserver(
      (entries) => {
        const entry = entries[0];
        if (entry.isIntersecting) {
          fetchPosts();
        }
      },
      {
        root: null,       // observe relative to the viewport
        rootMargin: "200px", // start loading 200px before the sentinel is visible
        threshold: 0,
      }
    );

    observer.observe(sentinel);

    return () => {
      observer.disconnect();
    };
  }, [fetchPosts]);

Then add the sentinel element at the bottom of the JSX, right before the closing </div>:

      {hasMore && <div ref={sentinelRef} style={{ height: 1 }} />}

That is it. The infinite scroll is now working.

How the Pieces Fit Together

  1. The component renders a list of posts and an invisible 1px-tall sentinel div at the bottom.
  2. The Intersection Observer watches the sentinel. Thanks to rootMargin: "200px", the callback fires when the sentinel is within 200 pixels of the viewport, giving the network request a head start.
  3. When the sentinel becomes visible, fetchPosts() runs, appends new posts to state, and increments the page counter.
  4. React re-renders the list. The sentinel is pushed further down. The cycle repeats.
  5. When the API returns an empty array, hasMore becomes false, the sentinel is removed from the DOM, and the observer naturally stops.

Step 3: Proper Error Handling

Network requests fail. APIs time out. Users go offline. A good infinite scroll React implementation must handle these scenarios gracefully.

We already capture errors in state. Now let’s give users a way to retry:

      {error && (
        <div style={{ textAlign: "center", padding: 16 }}>
          <p style={{ color: "red" }}>Something went wrong: {error}</p>
          <button onClick={fetchPosts}>Try Again</button>
        </div>
      )}

When an error occurs, the sentinel is still in the DOM, so the observer might keep firing and failing. To prevent this, update the observer callback to skip fetching when there is an active error:

        if (entry.isIntersecting && !error) {
          fetchPosts();
        }

Don’t forget to add error to the dependency array of the useEffect that creates the observer.

scrolling website list loading

Step 4: Extract a Reusable Custom Hook

If you use infinite scroll in multiple places across your app, duplicating all this logic is wasteful. Let’s extract it into a clean custom hook called useInfiniteScroll.

Create a file called useInfiniteScroll.js:

import { useState, useEffect, useRef, useCallback } from "react";

export default function useInfiniteScroll(fetchFn, options = {}) {
  const { rootMargin = "200px", threshold = 0 } = options;

  const [data, setData] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [hasMore, setHasMore] = useState(true);
  const sentinelRef = useRef(null);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;
    setLoading(true);
    setError(null);

    try {
      const newItems = await fetchFn(page);

      if (!newItems || newItems.length === 0) {
        setHasMore(false);
      } else {
        setData((prev) => [...prev, ...newItems]);
        setPage((prev) => prev + 1);
      }
    } catch (err) {
      setError(err.message || "An unknown error occurred");
    } finally {
      setLoading(false);
    }
  }, [page, loading, hasMore, fetchFn]);

  // Initial fetch
  useEffect(() => {
    loadMore();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  // Observer setup
  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && !error) {
          loadMore();
        }
      },
      { root: null, rootMargin, threshold }
    );

    observer.observe(sentinel);
    return () => observer.disconnect();
  }, [loadMore, error, rootMargin, threshold]);

  return { data, loading, error, hasMore, sentinelRef, retry: loadMore };
}

Using the Hook in a Component

import useInfiniteScroll from "./useInfiniteScroll";

const fetchPosts = async (page) => {
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=10`
  );
  if (!res.ok) throw new Error("Failed to fetch posts");
  return res.json();
};

export default function PostFeed() {
  const { data, loading, error, hasMore, sentinelRef, retry } =
    useInfiniteScroll(fetchPosts);

  return (
    <div style={{ maxWidth: 600, margin: "0 auto" }}>
      {data.map((post) => (
        <article key={post.id} style={{ padding: 16, borderBottom: "1px solid #eee" }}>
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </article>
      ))}

      {loading && <p>Loading...</p>}
      {error && (
        <div>
          <p style={{ color: "red" }}>{error}</p>
          <button onClick={retry}>Retry</button>
        </div>
      )}
      {!hasMore && <p>No more posts to load.</p>}
      {hasMore && <div ref={sentinelRef} style={{ height: 1 }} />}
    </div>
  );
}

Notice how clean the component is now. All the infinite scroll logic lives in the hook, and you can reuse it for any paginated endpoint.

Performance Considerations

An infinite scroll that works is good. An infinite scroll that works fast is better. Here are the performance aspects you should think about.

1. Use rootMargin to Prefetch

Setting rootMargin: "200px" (or even "400px") triggers the fetch before the user reaches the bottom. This creates the illusion of instant loading. Adjust the value based on your average network latency and item height.

2. Avoid Re-rendering the Entire List

Every time new posts are appended to state, React re-renders all items. For lists with hundreds or thousands of entries, this gets expensive. Solutions include:

  • React.memo: Wrap individual list items in React.memo so only new items trigger renders.
  • Virtualization: For very large lists (10,000+ items), pair infinite scroll with a virtualization library like @tanstack/react-virtual. This renders only the items currently visible in the viewport.

3. Clean Up Observers on Unmount

Our hook already calls observer.disconnect() in the cleanup function of useEffect. This prevents memory leaks when the component unmounts. Never skip this step.

4. Debounce Rapid Triggers

In some edge cases, the observer callback can fire multiple times before the first fetch completes. Our if (loading || !hasMore) return guard handles this. Without it, you could fire duplicate requests and end up with duplicate data.

5. Image Optimization

If your list items contain images, use lazy loading (loading="lazy" attribute) to avoid downloading off-screen images. This pairs perfectly with infinite scroll because you are already loading content on demand.

Infinite Scroll vs. Third-Party Libraries: A Comparison

Criteria Intersection Observer (Custom) react-infinite-scroll-component React Query + Infinite
Bundle size 0 KB (native API) ~4 KB ~13 KB
Customization Full control Limited to component props High (but focused on data fetching)
Error handling You build it Basic Built-in retry/refetch
Learning curve Medium Low Medium-High
Dependency risk None Maintained by community Actively maintained
TypeScript support You type it yourself Community types First-class

For most projects, the custom Intersection Observer approach gives you the best balance of performance, control, and simplicity. If your app already uses TanStack Query (React Query), its useInfiniteQuery hook is an excellent choice that pairs naturally with the observer pattern we described above.

scrolling website list loading

Adding TypeScript Support

Since many teams use TypeScript in 2026, here is a typed version of the hook signature:

interface UseInfiniteScrollOptions {
  rootMargin?: string;
  threshold?: number;
}

interface UseInfiniteScrollResult<T> {
  data: T[];
  loading: boolean;
  error: string | null;
  hasMore: boolean;
  sentinelRef: React.RefObject<HTMLDivElement | null>;
  retry: () => void;
}

export default function useInfiniteScroll<T>(
  fetchFn: (page: number) => Promise<T[]>,
  options?: UseInfiniteScrollOptions
): UseInfiniteScrollResult<T> {
  // ... same implementation as above, with proper types
}

This gives you full type safety on the returned data without any extra libraries.

Accessibility Tips

Infinite scroll can be frustrating for keyboard and screen reader users. A few things you can do:

  • Announce new content: Use an aria-live="polite" region to announce when new items are loaded (e.g., “10 more posts loaded”).
  • Provide a fallback: Consider offering a “Load More” button alongside (or as a fallback to) the automatic scroll behavior. Some users prefer explicit control.
  • Preserve focus: When new items appear, don’t steal focus from where the user currently is.
  • Footer reachability: If your page has a footer, infinite scroll can make it unreachable. Consider placing footer content in a sidebar or using a finite list with a “Show More” trigger.
scrolling website list loading

Common Pitfalls and How to Avoid Them

Duplicate Data

If the observer fires twice before the first fetch completes, you might request the same page twice. The loading guard in our loadMore function prevents this. Always check if (loading) return before making a request.

Stale Closures

A classic React hooks issue. If your fetchPosts function references stale state values, you will fetch the wrong page. Wrapping it in useCallback with the correct dependencies solves this.

Memory Leaks

If you forget to call observer.disconnect() in the useEffect cleanup, the observer persists after the component unmounts, potentially causing errors and memory leaks.

Infinite Re-renders

Be careful with dependency arrays. If fetchPosts is recreated on every render and used as a dependency for the observer’s useEffect, the observer will be destroyed and recreated endlessly. Using useCallback stabilizes the function reference.

Complete Working Code

Here is the full, final version of the component with all improvements included in a single file for reference:

import { useState, useEffect, useRef, useCallback } from "react";

const POSTS_PER_PAGE = 10;
const API_URL = "https://jsonplaceholder.typicode.com/posts";

export default function InfinitePostList() {
  const [posts, setPosts] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [hasMore, setHasMore] = useState(true);
  const sentinelRef = useRef(null);

  const fetchPosts = useCallback(async () => {
    if (loading || !hasMore) return;
    setLoading(true);
    setError(null);

    try {
      const res = await fetch(
        `${API_URL}?_page=${page}&_limit=${POSTS_PER_PAGE}`
      );
      if (!res.ok) throw new Error(`HTTP error ${res.status}`);
      const newPosts = await res.json();

      if (newPosts.length === 0) {
        setHasMore(false);
      } else {
        setPosts((prev) => [...prev, ...newPosts]);
        setPage((prev) => prev + 1);
      }
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  }, [page, loading, hasMore]);

  useEffect(() => {
    fetchPosts();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && !error) {
          fetchPosts();
        }
      },
      { root: null, rootMargin: "200px", threshold: 0 }
    );

    observer.observe(sentinel);
    return () => observer.disconnect();
  }, [fetchPosts, error]);

  return (
    <div style={{ maxWidth: 640, margin: "0 auto", padding: 20 }}>
      <h1>Infinite Scroll Demo</h1>
      {posts.map((post) => (
        <article
          key={post.id}
          style={{ padding: 16, borderBottom: "1px solid #e0e0e0" }}
        >
          <h3>{post.title}</h3>
          <p>{post.body}</p>
        </article>
      ))}

      {loading && <p style={{ textAlign: "center" }}>Loading...</p>}

      {error && (
        <div style={{ textAlign: "center", padding: 16 }}>
          <p style={{ color: "red" }}>{error}</p>
          <button onClick={fetchPosts}>Retry</button>
        </div>
      )}

      {!hasMore && <p style={{ textAlign: "center" }}>End of list.</p>}
      {hasMore && <div ref={sentinelRef} style={{ height: 1 }} />}
    </div>
  );
}

Frequently Asked Questions

Is infinite scroll in React bad for SEO?

Infinite scroll can be problematic for SEO because search engine crawlers may not trigger scroll events. If SEO matters for your content, consider server-side rendering (SSR) with pagination, or implement a hybrid approach where the URL updates with each loaded page so crawlers can follow paginated links.

Can I use this approach with React Server Components?

The Intersection Observer requires access to the DOM, so the infinite scroll logic must run in a Client Component. You can still use Server Components for the initial data fetch and pass the results as props to a Client Component that handles subsequent loading.

How do I handle scrolling back up to previously loaded items?

Items already in state remain rendered. However, if you have thousands of items, consider windowing or virtualization with a library like @tanstack/react-virtual to keep memory usage low while preserving scroll position.

Should I use react-infinite-scroll-component instead?

If you want a quick drop-in solution and do not need fine-grained control, it works fine. However, implementing infinite scroll yourself with the Intersection Observer API gives you zero dependency overhead, full control over behavior, and a deeper understanding of how it works. For production applications, the custom approach is often the better long-term choice.

Does this work with React Native?

No. The Intersection Observer is a browser API and is not available in React Native. For React Native, use the onEndReached prop on FlatList, which provides similar functionality natively.

How do I reset the infinite scroll (e.g., when a filter changes)?

Reset all state values: set posts to an empty array, page back to 1, hasMore to true, and error to null. Then trigger the initial fetch again. If you use the custom hook, you can expose a reset function for this purpose.

Wrapping Up

Implementing infinite scroll in React does not require heavy dependencies. The Intersection Observer API is performant, widely supported, and gives you complete control over the user experience. Combined with proper loading states, error handling, and a reusable custom hook, you get a solution that is production-ready and easy to maintain.

The key takeaways:

  1. Use a sentinel element at the bottom of your list and observe it with IntersectionObserver.
  2. Set rootMargin to prefetch data before the user reaches the end.
  3. Guard against duplicate requests with a loading check.
  4. Always clean up observers in your useEffect return function.
  5. Extract the logic into a reusable hook for cleaner components.

Start with the code in this tutorial, adapt the fetchFn to your own API, and you will have a smooth infinite scroll experience running in minutes.