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
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;
The important state variables are:
-
postsstores the posts already loaded. -
pagekeeps track of which batch should be requested next. -
loadingprevents multiple requests from running at the same time. -
hasMoretells 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();
};
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
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]);
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);
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]);
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>
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;
Why Use IntersectionObserver?
A common alternative is listening for the browser's scroll event:
window.addEventListener("scroll", handleScroll);
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)
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
The server could return:
{
"posts": [],
"nextCursor": "xyz789",
"hasMore": true
}
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
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"
/>
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];
});
Endless Requests
Always maintain a clear stopping condition such as:
hasMore
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:
- Fetch the first batch of posts.
- Render the posts.
- Place a sentinel element at the bottom.
- Monitor it with
IntersectionObserver. - Request the next batch when it becomes visible.
- Stop when there is no more content.
- 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)