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, and retry
  • ✔ 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>
);
Enter fullscreen mode Exit fullscreen mode

Real Use Case: Reset on Filter Change

This is where most bugs happen.

useEffect(() => {
  reset(); // cancels old request + reloads safely
}, [filter]);
Enter fullscreen mode Exit fullscreen mode

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.