Infinite Scroll in React: Intersection Observer vs React Query
Pick the wrong infinite scroll approach in React and you'll pay for it in jank and memory leaks. Here's how Intersection Observer and React Query compare — and when to use each.
Why Infinite Scroll Is Harder Than It Looks
Infinite scroll sounds simple. User scrolls down, you load more data. Done, right? Not quite. You've got race conditions to worry about, duplicate requests when a user scrolls too fast, memory bloat when you've rendered 2,000 DOM nodes, and cleanup logic that most tutorials skip entirely.
The two most common approaches in 2026 are rolling your own with the browser's IntersectionObserver API, or delegating the whole thing to React Query's useInfiniteQuery hook. Both work. They solve slightly different problems. And the one you pick should depend on how much server-state complexity you're actually dealing with.
Look, if you're just loading a simple list from a REST endpoint and you don't already have React Query in your stack, a raw IntersectionObserver hook is 40 lines and zero dependencies. But if you're already using React Query for caching, background refetching, or stale-while-revalidate patterns — useInfiniteQuery is genuinely the better call. Don't add complexity where none is needed.
How IntersectionObserver Actually Works
The IntersectionObserver API lets you watch when a DOM element enters or leaves the viewport. You attach it to a sentinel element — a tiny invisible div you drop at the bottom of your list — and when that element becomes visible, you fire your fetch. No scroll event listeners, no getBoundingClientRect polling. It's efficient by default.
Here's a minimal custom hook you can drop into any React project. This runs on React 18+ and uses a rootMargin of 200px so you start loading before the user actually hits the bottom — which gives you that smooth, no-wait feel:
import { useEffect, useRef, useCallback } from 'react';
export function useInfiniteScroll(onLoadMore, hasMore) {
const sentinelRef = useRef(null);
const handleIntersect = useCallback(
(entries) => {
const [entry] = entries;
if (entry.isIntersecting && hasMore) {
onLoadMore();
}
},
[onLoadMore, hasMore]
);
useEffect(() => {
const observer = new IntersectionObserver(handleIntersect, {
rootMargin: '200px',
});
const el = sentinelRef.current;
if (el) observer.observe(el);
return () => {
if (el) observer.unobserve(el);
};
}, [handleIntersect]);
return sentinelRef;
}You'd pair this with a useState + useEffect setup that accumulates pages. The downside? You're managing all that state yourself — loading flags, error states, page cursors. It's not a lot of code, but it's code you have to maintain.
Worth noting: IntersectionObserver has had solid browser support since 2018, so you don't need a polyfill for anything modern. If you're targeting very old WebViews, check Can I Use.
React Query's useInfiniteQuery: The Managed Approach
React Query's useInfiniteQuery (v5 as of 2026) treats paginated data as a first-class concern. It manages the page accumulation, tracks hasNextPage and isFetchingNextPage for you, handles background refetching, and deduplicates in-flight requests automatically.
Here's the same infinite list, but with useInfiniteQuery doing the heavy lifting:
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInfiniteScroll } from './useInfiniteScroll';
async function fetchPosts({ pageParam = 1 }) {
const res = await fetch(`/api/posts?page=${pageParam}&limit=20`);
return res.json();
}
export function PostFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
});
const sentinelRef = useInfiniteScroll(fetchNextPage, !!hasNextPage);
return (
<div>
{data?.pages.flatMap((page) => page.items).map((post) => (
<PostCard key={post.id} post={post} />
))}
<div ref={sentinelRef} style={{ height: 1 }} />
{isFetchingNextPage && <Spinner />}
</div>
);
}Notice you still use the IntersectionObserver hook — but now it's only responsible for triggering fetchNextPage. React Query owns everything else: caching, deduplication, error retries, stale time. That's a clean separation of concerns.
In practice, this combination is what I'd reach for on any serious app. The raw hook alone is fine for prototypes or simple cases, but once you need cache invalidation or want to prefetch the next page before the user reaches the sentinel, React Query is worth the dependency.
Handling Edge Cases That Will Bite You
Most tutorials show you the happy path and call it a day. But there are a few edge cases that will definitely trip you up in production.
First: rapid scrolling. If a user scrolls past the sentinel faster than your debounce or your isFetchingNextPage guard, you can fire duplicate requests. React Query handles this automatically — it won't fire fetchNextPage if a fetch is already in progress. With the raw approach, you need to gate on a loadingRef yourself.
Second: virtualization. At 500+ items, you're going to notice frame drops. Rendering thousands of real DOM nodes is expensive, full stop. Libraries like @tanstack/react-virtual or react-window solve this, but they require your list items to have a known (or estimated) height. Plan for this early — retrofitting virtualization into an existing list component is annoying.
One more thing — cleanup. When you navigate away from a page mid-scroll, you want to make sure the IntersectionObserver is disconnected and any pending state updates are aborted. The useEffect cleanup in the hook above handles the observer, but if you're using a raw fetch or axios call in the plain hook version, you'll want an AbortController too.
Performance: What Actually Matters
The intersection between performance and UX here is the rootMargin value on your observer. Set it too low — like 0px — and users will see a loading spinner before new content appears. Set it too high — 1000px — and you're prefetching multiple pages the user might never see, burning bandwidth and potentially rate-limiting yourself.
Honestly, 200px–400px is the sweet spot for most feeds. That's enough lead time on a typical 4G connection to load the next page before the user reaches the bottom, without aggressively over-fetching. Adjust based on how heavy your API responses are.
If you're building a UI-heavy component list and want inspiration for how smooth infinite scroll looks when done right, check out how Empire UI handles component browsing — the component library uses a pattern close to what's described here. You can also use the gradient generator or box shadow generator to style your loading skeletons while new content comes in.
Quick aside: skeleton loaders beat spinners every time for perceived performance. A spinner tells the user "wait." A skeleton says "content is coming, here's roughly what it looks like." It's a small thing, but it meaningfully reduces perceived latency in user testing.
Choosing Between the Two Approaches
Here's the honest decision tree. Are you already using React Query in your project? Use useInfiniteQuery. It's more code to set up initially, but you get deduplication, cache management, and background refresh for free. The combination with a sentinel-based IntersectionObserver hook is the pattern most large-scale React apps should be using in 2026.
Not using React Query and don't want to add it? The custom useIntersectionObserver + local useState accumulation is totally valid. You'll write maybe 60–80 lines total and you won't regret it on a small app. The key is to guard against duplicate fetches with a ref-based loading flag, and to always clean up your observer.
If your feed is purely client-side filtered (no API calls, just filtering a big in-memory array), you don't need infinite scroll at all — just windowing. Use @tanstack/react-virtual directly and skip the observer entirely. Don't add network-pagination complexity to a problem that's actually a rendering problem.
What if you're server-side rendering with Next.js? That's a whole other article, but the short version is: hydration of infinite scroll state is tricky. React Query has built-in support for dehydrating and rehydrating infinite query data, which is another point in its favor for SSR apps.
FAQ
Yes, since 2018 it's in every major browser including mobile Safari. You only need a polyfill if you're targeting IE11 or ancient WebViews — which, at this point, you probably aren't.
Absolutely. You could trigger fetchNextPage on a button click, on a scroll event, or on a timer. The observer is just the most ergonomic trigger for scroll-based loading.
With React Query, include your filter values in the queryKey array — the query auto-resets when the key changes. With the manual approach, reset your page state to 1 and clear your accumulated items array when the filter updates.
If you're regularly hitting 200+ rendered items, yes. Below that threshold the browser handles it fine. Use @tanstack/react-virtual — it integrates well with both the manual and React Query approaches.