Infinite scrolling lets users load more data seamlessly as they scroll down, improving user experience for long lists like feeds or product pages.
🧠 Core Concepts
- Pagination API → Fetch next page of data using page number or cursor.
- Intersection Observer / Scroll Event → Detect when the user reaches the end.
- Error Handling & Retry → Handle API errors gracefully without breaking the UI.
- Scrollable Container → Observe the container instead of the entire window.
🧾 Step-by-Step Implementation
import React, { useEffect, useRef, useState, useCallback } from "react";
export default function InfiniteScrollList() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [hasMore, setHasMore] = useState(true);
const loaderRef = useRef(null);
const containerRef = useRef(null);
const fetchData = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=10`);
if (!res.ok) throw new Error("Failed to fetch");
const data = await res.json();
setItems(prev => [...prev, ...data]);
setHasMore(data.length > 0);
setPage(prev => prev + 1);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}, [page, loading, hasMore]);
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting) {
fetchData();
}
},
{
root: containerRef.current, // 👈 key difference (container not window)
rootMargin: "0px",
threshold: 1.0
}
);
const currentLoader = loaderRef.current;
if (currentLoader) observer.observe(currentLoader);
return () => {
if (currentLoader) observer.unobserve(currentLoader);
};
}, [fetchData]);
return (
<div
ref={containerRef}
style={{
height: "400px",
overflowY: "auto",
border: "1px solid #ccc",
padding: "1rem"
}}
>
{items.map(item => (
<div key={item.id} style={{ marginBottom: "10px", padding: "8px", background: "#f5f5f5" }}>
{item.title}
</div>
))}
{error && (
<div>
<p style={{ color: "red" }}>{error}</p>
<button onClick={fetchData}>Retry</button>
</div>
)}
{loading && <p>Loading...</p>}
<div ref={loaderRef}></div>
</div>
);
}
🧭 Why This Works
- Pagination: Uses
_pageand_limitquery params to load data in chunks. - Intersection Observer: Watches the loader div at the end of the list and triggers
fetchDatawhen it enters the viewport. - Container Scroll: By setting
roottocontainerRef.current, we detect the scroll inside the container instead of the whole window. - Error Handling: Catches errors and gives the user a retry option.
- hasMore Flag: Prevents extra unnecessary API calls when no more data is available.
🧪 Pro Tips
- Use libraries like React Query or SWR to simplify caching, retries, and pagination.
- Add debouncing if using scroll events instead of Intersection Observer.
- Add loading skeletons instead of plain text for a better UX.
- Handle duplicate requests by locking fetch calls with a
loadingflag.