DEV Community

Lacey Glenn
Lacey Glenn

Posted on

How to Build an Infinite Scroll Feed with React

Infinite scrolling is commonly used in social media feeds, news websites, marketplaces, and content platforms. Instead of loading every post at once, the application loads a small batch of content and fetches more when the user reaches the bottom of the page.

This approach can improve the initial loading experience and reduce the amount of data requested at once.

In this tutorial, we'll build a simple infinite scroll feed with React using the browser's IntersectionObserver API.

What We'll Build

Our example will:

  • Display a list of posts
  • Load posts in batches
  • Detect when the user reaches the bottom
  • Fetch the next batch automatically
  • Show a loading indicator
  • Stop requesting data when there are no more posts
  • Handle basic API errors

1. Create a React Application

If you're starting a new project, you can create one with Vite:

npm create vite@latest infinite-scroll-feed -- --template react
cd infinite-scroll-feed
npm install
npm run dev
Enter fullscreen mode Exit fullscreen mode

For this example, we'll keep the application simple and focus on the infinite-scroll logic.

2. Create a Feed Component

Start with a basic component that stores the posts and loading state.

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

function Feed() {
  const [posts, setPosts] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  return (
    <div>
      <h1>Social Feed</h1>

      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.body}</p>
        </article>
      ))}

      {loading && <p>Loading...</p>}
    </div>
  );
}

export default Feed;
Enter fullscreen mode Exit fullscreen mode

The important state variables are:

  • posts stores the posts already loaded.
  • page keeps track of which batch should be requested next.
  • loading prevents multiple requests from running at the same time.
  • hasMore tells us whether additional posts are available.

3. Fetch Posts from an API

For demonstration, we can use a simple API such as JSONPlaceholder.

Create a function that retrieves a batch of posts:

const fetchPosts = async (pageNumber) => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts?_page=${pageNumber}&_limit=10`
  );

  if (!response.ok) {
    throw new Error("Failed to fetch posts");
  }

  return response.json();
};
Enter fullscreen mode Exit fullscreen mode

The _page parameter tells the API which page we want, while _limit controls how many posts are returned.

In a production application, your API might look more like:

/api/posts?page=2&limit=20
Enter fullscreen mode Exit fullscreen mode

4. Load the Initial Posts

We can use useEffect to load the first batch when the component mounts.

useEffect(() => {
  const loadPosts = async () => {
    setLoading(true);

    try {
      const newPosts = await fetchPosts(page);

      setPosts((currentPosts) => [
        ...currentPosts,
        ...newPosts
      ]);

      if (newPosts.length === 0) {
        setHasMore(false);
      }
    } catch (error) {
      console.error(error);
    } finally {
      setLoading(false);
    }
  };

  loadPosts();
}, [page]);
Enter fullscreen mode Exit fullscreen mode

Whenever page changes, React runs the effect again and loads the next batch.

However, we still need a way to automatically increase the page number when the user reaches the bottom.

5. Detect the Bottom of the Feed

This is where IntersectionObserver becomes useful.

Create a reference for a small element placed at the bottom of the feed:

const loaderRef = useRef(null);
Enter fullscreen mode Exit fullscreen mode

Then add the observer:

useEffect(() => {
  const observer = new IntersectionObserver(
    (entries) => {
      if (entries[0].isIntersecting && !loading && hasMore) {
        setPage((currentPage) => currentPage + 1);
      }
    },
    {
      threshold: 1.0
    }
  );

  if (loaderRef.current) {
    observer.observe(loaderRef.current);
  }

  return () => observer.disconnect();
}, [loading, hasMore]);
Enter fullscreen mode Exit fullscreen mode

The observer watches the element at the bottom of the feed.

When that element becomes visible, we increase the page number.

6. Add the Loader Element

Place the loader at the end of the posts:

{posts.map((post) => (
  <article key={post.id}>
    <h2>{post.title}</h2>
    <p>{post.body}</p>
  </article>
))}

<div ref={loaderRef}>
  {loading && <p>Loading more posts...</p>}
</div>
Enter fullscreen mode Exit fullscreen mode

When the user approaches the bottom, the loader enters the viewport and triggers another request.

7. Complete Example

Here's the complete component:

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

function Feed() {
  const [posts, setPosts] = useState([]);
  const [page, setPage] = useState(1);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  const loaderRef = useRef(null);

  const fetchPosts = async (pageNumber) => {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/posts?_page=${pageNumber}&_limit=10`
    );

    if (!response.ok) {
      throw new Error("Failed to fetch posts");
    }

    return response.json();
  };

  useEffect(() => {
    const loadPosts = async () => {
      setLoading(true);

      try {
        const newPosts = await fetchPosts(page);

        setPosts((currentPosts) => [
          ...currentPosts,
          ...newPosts
        ]);

        if (newPosts.length === 0) {
          setHasMore(false);
        }
      } catch (error) {
        console.error(error);
      } finally {
        setLoading(false);
      }
    };

    loadPosts();
  }, [page]);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && !loading && hasMore) {
          setPage((currentPage) => currentPage + 1);
        }
      },
      {
        threshold: 1.0
      }
    );

    if (loaderRef.current) {
      observer.observe(loaderRef.current);
    }

    return () => observer.disconnect();
  }, [loading, hasMore]);

  return (
    <main>
      <h1>Social Feed</h1>

      {posts.map((post) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.body}</p>
        </article>
      ))}

      <div ref={loaderRef}>
        {loading && <p>Loading more posts...</p>}

        {!hasMore && <p>No more posts.</p>}
      </div>
    </main>
  );
}

export default Feed;
Enter fullscreen mode Exit fullscreen mode

Why Use IntersectionObserver?

A common alternative is listening for the browser's scroll event:

window.addEventListener("scroll", handleScroll);
Enter fullscreen mode Exit fullscreen mode

This can work, but scroll events may fire very frequently. You then need to calculate the user's position and determine whether they've reached the bottom.

IntersectionObserver provides a cleaner approach by allowing the browser to notify your application when an element enters or leaves the viewport.

Handling Duplicate Requests

Infinite scrolling can accidentally trigger multiple requests if the user reaches the bottom while a request is still running.

That's why this condition is important:

if (entries[0].isIntersecting && !loading && hasMore)
Enter fullscreen mode Exit fullscreen mode

The !loading check prevents another page request while the current request is still being processed.

For more complex applications, you can also use AbortController to cancel requests when necessary.

Infinite Scroll for a Real Social Media App

A real social media feed usually needs more than a basic page number.

For example, an API might use cursor-based pagination:

GET /api/feed?limit=20&cursor=abc123
Enter fullscreen mode Exit fullscreen mode

The server could return:

{
  "posts": [],
  "nextCursor": "xyz789",
  "hasMore": true
}
Enter fullscreen mode Exit fullscreen mode

Cursor-based pagination is often useful for feeds where new content is continuously being created because it can avoid some of the consistency problems associated with traditional page numbers.

The frontend can then request:

/api/feed?limit=20&cursor=xyz789
Enter fullscreen mode Exit fullscreen mode

to retrieve the next batch.

Performance Considerations

Infinite scrolling doesn't automatically make an application fast.

If thousands of posts remain mounted in the DOM, the page can eventually become expensive to render.

For large feeds, consider:

1. Virtualization

Libraries such as react-window can render only the items currently visible or near the viewport.

2. Image Lazy Loading

Use lazy loading for images:

<img
  src={post.image}
  alt={post.title}
  loading="lazy"
/>
Enter fullscreen mode Exit fullscreen mode

3. Smaller API Responses

Only request the fields needed by the feed.

Instead of returning a large user profile, media metadata, and unrelated information, return the data required to render each feed item.

4. Cursor-Based Pagination

For large and frequently changing feeds, cursor-based pagination can provide more consistent results than simple page numbers.

5. Caching

Caching previously requested data can reduce unnecessary network requests and improve navigation.

Common Problems

Duplicate Posts

This can happen when pagination requests overlap or when the backend changes between requests.

Use stable IDs and carefully manage pagination state.

setPosts((currentPosts) => {
  const existingIds = new Set(currentPosts.map((post) => post.id));

  const uniquePosts = newPosts.filter(
    (post) => !existingIds.has(post.id)
  );

  return [...currentPosts, ...uniquePosts];
});
Enter fullscreen mode Exit fullscreen mode

Endless Requests

Always maintain a clear stopping condition such as:

hasMore
Enter fullscreen mode Exit fullscreen mode

Otherwise, the observer can continue requesting pages indefinitely.

Poor Mobile Performance

Large images, excessive DOM nodes, expensive components, and unnecessary re-renders can make an infinite feed feel slow on mobile devices.

Profile the application before adding optimization blindly.

Conclusion

Building an infinite scroll feed with React doesn't require complicated code. The basic pattern is:

  1. Fetch the first batch of posts.
  2. Render the posts.
  3. Place a sentinel element at the bottom.
  4. Monitor it with IntersectionObserver.
  5. Request the next batch when it becomes visible.
  6. Stop when there is no more content.
  7. Add virtualization, caching, and optimized media handling as the feed grows.

For a small application, page-based pagination may be enough. For a large social platform, you'll typically need additional considerations such as cursor-based pagination, caching, virtualization, efficient APIs, and scalable backend architecture.

Top comments (0)