I used to believe infinite scroll was one of the simplest features to implement.
Fetch data → append to a list → load more on scroll.
Easy, right?
That’s exactly how most tutorials show it.
And honestly… it works.
Until it doesn’t.
The Bug That Changed Everything
One bug completely changed how I think about infinite scroll.
A user changed a filter → the list reset → new data loaded.
Everything looked correct.
Then a couple of seconds later…
the old results came back and overwrote the new ones.
No errors. No warnings.
Just silently broken UI.
What actually happened?
A slow request from the previous state finished late and updated the UI with stale data.
That was the moment I realized:
Infinite scroll doesn’t break in demos.It breaks with real users, real timing, and real networks.
Where Things Start Falling Apart
After that, I started noticing the same issues again and again:
- Stale requests overwriting fresh data after reset
- Retry logic fetching the wrong page
- IntersectionObserver continuing to fire after errors
- Duplicate requests in React Strict Mode
- Loading states that never resolve
These problems rarely show up locally.
But in production, they show up fast.
What I Actually Needed
Not another demo.
Something I could trust in a real app.
So I built a hook around one idea:
Control the async flow. Don’t just “load more data”
What This Hook Handles
- ✔ Cancels stale requests (no overwrite bugs)
- ✔ Smart retry logic (correct page every time)
- ✔ Proper observer cleanup (no leaks)
- ✔ React 18+ Strict Mode safe
- ✔ Clear initial vs pagination loading states
- ✔ Manual
loadMore,reset, andretry - ✔ Zero dependencies
Basic Usage
const {
items,
isInitialLoading,
hasMore,
error,
loadMoreRef,
retry,
reset
} = useInfiniteScroll({
fetchFn: async (page, signal) => {
const res = await fetch(`/api/posts?page=${page}`, { signal });
return res.json();
},
pageSize: 20,
});
return (
<div>
<ul>
{items.map((item) => (
<li key={item.id}>{item.title}</li>
))}
</ul>
{/* Sentinel element */}
<div ref={loadMoreRef} />
{error && <button onClick={retry}>Retry</button>}
</div>
);
Real Use Case: Reset on Filter Change
This is where most bugs happen.
useEffect(() => {
reset(); // cancels old request + reloads safely
}, [filter]);
Without proper handling, this is exactly where stale data bugs appear.
Why AbortController Matters So Much
Before using it:
old requests still resolved
stale data overwrote the UI
After using it:
old requests get cancelled immediately
they never reach your state
That single change removes an entire class of bugs.
One Honest Note
This hook handles most real-world issues.
But for very large lists, you should still use virtualization
(like react-window or tanstack/virtual).
Infinite scroll solves loading.
Virtualization solves rendering.
Try It Yourself
You can grab the full hook here:
👉 https://shubhra.dev/snippets/use-infinite-scroll
MIT licensed
free to copy and modify
built for real production cases
Final Thought
I didn’t build this because infinite scroll is hard.
I built it because the small edge cases are what break real products.
Slow APIs. Fast users. Overlapping requests.
That’s where things actually fall apart.
Curious - what broke for you?
Have you ever hit a weird infinite scroll bug in production?
Something like:
- duplicate data
- stale UI
- loading that never stops
Would genuinely love to hear what you ran into.
Top comments (0)