Infinite Scroll in React: Intersection Observer, React Query, Virtualization
Build infinite scroll in React the right way — Intersection Observer for triggers, React Query for data fetching, and virtualization so your DOM doesn't explode.
Why Infinite Scroll Is Harder Than It Looks
Infinite scroll sounds trivial. Scroll down, load more stuff. But if you've built it more than once you already know the real story — a naive implementation quietly destroys performance, breaks browser history, and makes accessibility a nightmare, all at the same time.
The classic amateur approach is a scroll event listener on window. You listen for when scrollY + innerHeight >= document.body.scrollHeight - threshold, fire a fetch, append items. It works. It also fires hundreds of times per second, even when you debounce it. In practice, debouncing kills responsiveness on fast scrollers and still wastes CPU on slow ones. There's a better primitive.
Since 2018, the Intersection Observer API has been the correct tool for this job. It's browser-native, runs off the main thread, and costs essentially nothing when no intersection is happening. Pair it with React Query 5's useInfiniteQuery for data layer, and optionally TanStack Virtual for DOM virtualization, and you've got a production-grade infinite scroll that can handle tens of thousands of items without sweating.
This guide walks through all three layers in that order — scroll trigger, data fetching, then virtualization. You can stop after layer two if your lists stay under ~500 items. You won't need the full stack for a blog feed, but you will need it for a product catalogue or a social timeline.
The Scroll Sentinel Pattern with Intersection Observer
The core idea: render an invisible <div> at the bottom of your list (the "sentinel"), then use IntersectionObserver to watch it. When the sentinel enters the viewport, load the next page. When loading is done, the list grows, the sentinel moves down, and the cycle repeats.
Here's a reusable useIntersection hook that covers 95% of cases:
// hooks/useIntersection.ts
import { useEffect, useRef, useState } from 'react';
export function useIntersection(
options?: IntersectionObserverInit
): [React.RefObject<HTMLDivElement>, boolean] {
const ref = useRef<HTMLDivElement>(null);
const [isIntersecting, setIsIntersecting] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => setIsIntersecting(entry.isIntersecting),
{ threshold: 0, rootMargin: '200px', ...options }
);
observer.observe(el);
return () => observer.disconnect();
}, []);
return [ref, isIntersecting];
}The rootMargin: '200px' is intentional — it fires the callback 200px before the sentinel actually enters the viewport, giving you a head start on the fetch. Users on fast connections won't see a loading spinner at all. On slower connections they'll see it for a beat but won't hit a hard stop. Adjust this number to taste; 100px is fine for paginated APIs, 400px makes sense for heavy image grids.
Worth noting: IntersectionObserver is supported in every browser that matters as of 2024. Safari added full support in 12.1. You don't need a polyfill unless you're targeting ancient WebViews — in which case, you have bigger problems.
One more thing — the disconnect() cleanup in the useEffect return is not optional. Skip it and you'll leak observers every time the component re-mounts. In development mode with React 18's strict double-invocation, you'll create two observers for every one sentinel. Always clean up.
Wiring It to React Query's useInfiniteQuery
React Query 5 ships useInfiniteQuery specifically for this pattern. It manages cursor-based or page-based pagination, caches each page separately, and handles background refetch and stale state correctly. You'd spend a weekend reimplementing a fraction of this with raw useState and useEffect.
Here's the full data layer for a product feed:
// hooks/useProducts.ts
import { useInfiniteQuery } from '@tanstack/react-query';
interface Product {
id: string;
name: string;
price: number;
image: string;
}
interface ProductsPage {
items: Product[];
nextCursor: string | null;
}
async function fetchProducts(
cursor?: string,
limit = 24
): Promise<ProductsPage> {
const params = new URLSearchParams({ limit: String(limit) });
if (cursor) params.set('cursor', cursor);
const res = await fetch(`/api/products?${params}`);
if (!res.ok) throw new Error('Failed to fetch products');
return res.json();
}
export function useProducts() {
return useInfiniteQuery({
queryKey: ['products'],
queryFn: ({ pageParam }) => fetchProducts(pageParam),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
}The getNextPageParam callback is where the cursor logic lives. Return undefined and React Query knows there are no more pages — hasNextPage becomes false automatically. Your API just needs to return a null cursor when you've exhausted the dataset. Most REST APIs and all GraphQL cursor-based connections follow this pattern already.
Connecting the hook to the sentinel is four lines:
// components/ProductFeed.tsx
import { useEffect } from 'react';
import { useIntersection } from '../hooks/useIntersection';
import { useProducts } from '../hooks/useProducts';
export function ProductFeed() {
const [sentinelRef, isIntersecting] = useIntersection();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useProducts();
useEffect(() => {
if (isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [isIntersecting, hasNextPage, isFetchingNextPage]);
if (status === 'pending') return <p>Loading...</p>;
if (status === 'error') return <p>Something went wrong.</p>;
const allProducts = data.pages.flatMap((page) => page.items);
return (
<div>
<ul className="grid grid-cols-3 gap-6">
{allProducts.map((product) => (
<li key={product.id}>
<img src={product.image} alt={product.name} />
<p>{product.name}</p>
<p>${product.price}</p>
</li>
))}
</ul>
{/* sentinel */}
<div ref={sentinelRef} aria-hidden="true" />
{isFetchingNextPage && <p>Loading more...</p>}
{!hasNextPage && <p>You've seen everything.</p>}
</div>
);
}Honestly, this is all you need for most apps. A product catalogue with a few thousand items, a blog archive, a user directory — this handles all of it cleanly. The React Query cache means hitting the back button restores the exact scroll position and page state without a refetch. That alone is worth the dependency.
DOM Virtualization with TanStack Virtual
Here's the problem: after a user scrolls through 50 pages of 24 items each, you've got 1,200 DOM nodes. Each one has layout, paint, and compositor cost. At 5,000 items the page starts to stutter on mid-range laptops. At 10,000 you're in real trouble. Virtualization solves this by only rendering the items actually visible in the viewport, plus a small overscan buffer.
TanStack Virtual 3 (released in 2023, now stable) is the best option here. It's headless — just gives you the math, you supply the markup — and it integrates cleanly with useInfiniteQuery's flat list of items.
// components/VirtualProductFeed.tsx
import { useEffect, useRef } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useIntersection } from '../hooks/useIntersection';
import { useProducts } from '../hooks/useProducts';
export function VirtualProductFeed() {
const [sentinelRef, isIntersecting] = useIntersection();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useProducts();
const allProducts = data?.pages.flatMap((p) => p.items) ?? [];
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: hasNextPage ? allProducts.length + 1 : allProducts.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 320, // estimated item height in px
overscan: 5,
});
useEffect(() => {
if (isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [isIntersecting, hasNextPage, isFetchingNextPage]);
return (
<div
ref={parentRef}
style={{ height: '100vh', overflowY: 'auto' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const isLoader = virtualItem.index > allProducts.length - 1;
const product = allProducts[virtualItem.index];
return (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
{isLoader ? (
<div ref={sentinelRef}>Loading...</div>
) : (
<div className="p-4 border rounded-xl">
<img src={product.image} alt={product.name} />
<p>{product.name}</p>
</div>
)}
</div>
);
})}
</div>
</div>
);
}The trick with the virtualizer sentinel is adding one extra item slot (allProducts.length + 1 when hasNextPage is true), then rendering the loader/sentinel div in that last slot. When the virtualizer scrolls it into view, useIntersection fires, React Query fetches, and the count grows. It's a clean integration.
Quick aside: estimateSize: () => 320 is just an estimate. TanStack Virtual measures items after render and corrects positions automatically. The more accurate your estimate, the less visual jitter you get during initial render. If your items are fixed height (say, exactly 80px for a list row), set that exact value and you get perfect positioning from the first paint.
In practice, you won't need virtualization for most UIs. But if you're building something like an image gallery, a design library browser (like Empire UI where we render hundreds of component previews), or a social feed — virtual lists are non-negotiable. The DOM budget on mobile is real and it runs out fast.
Error Handling, Loading States, and UX Polish
The happy path is the easy part. What happens when page 7 of 20 fails? With raw useEffect and useState you're writing retry logic yourself. With React Query 5, useInfiniteQuery gives you isError, error, and refetch out of the box. You can surface a per-page retry button without touching your fetch logic.
{status === 'error' && (
<div className="text-center py-8">
<p className="text-red-500 mb-4">Failed to load more items.</p>
<button
onClick={() => fetchNextPage()}
className="px-4 py-2 bg-white/10 backdrop-blur-md border border-white/20 rounded-lg"
>
Try again
</button>
</div>
)}That glassmorphism button style above is a nod to Empire UI's glassmorphism components — if your feed lives on a dark background, glass buttons slot in perfectly without any extra design work. For the loading spinner between pages, keep it lightweight: a simple CSS @keyframes spin on a 24px SVG circle beats pulling in a library just for a loader.
For accessibility: the feed container should have role="feed" and each item role="article". Screen readers then know to announce individual items as the user navigates. Add aria-busy={isFetchingNextPage} on the container so assistive tech can announce the loading state. The sentinel div should carry aria-hidden="true" — it's presentational only.
That said, infinite scroll itself has an inherent accessibility problem: keyboard users and screen reader users can't easily jump to footer content, and there's no clean "where am I in the list" orientation. For those audiences, a "Load more" button pattern is often better — and React Query supports it identically, just triggered by a click instead of an intersection.
Caching, URL State, and the Back-Button Problem
Here's where most infinite scroll implementations fall apart. You've built a beautiful feed. User scrolls 10 pages deep, clicks into a product detail page, hits back — and they're back at the top. All that scroll progress: gone. Users hate this.
React Query's built-in page cache survives navigation in most setups, but you also need to restore scroll position. The simplest approach is sessionStorage: save window.scrollY before navigation, restore it on mount.
// In your feed component
useEffect(() => {
const saved = sessionStorage.getItem('feedScroll');
if (saved) window.scrollTo(0, parseInt(saved, 10));
return () => {
sessionStorage.setItem('feedScroll', String(window.scrollY));
};
}, []);If you're using TanStack Virtual, scroll restoration is trickier — you need to restore both the virtualizer's scroll offset AND ensure React Query has already hydrated the right number of pages. The cleanest pattern is encoding the current page count in the URL (?pages=7) and reading it on mount to call fetchNextPage enough times to reach that state. Ugly but it works, and it makes deep links shareable.
For Next.js apps, check out the page transitions patterns on the Empire UI blog — some of those techniques for preserving component state across route changes apply directly here. Worth pairing with next/navigation's useSearchParams for the URL-state approach.
Performance Checklist Before You Ship
Before pushing an infinite scroll feature to production, run through this list. Each one has bitten real products.
First: are your images lazy-loaded? With loading="lazy" on <img> tags and a virtualizer, you'll only decode images in view. Without lazy loading, the browser tries to fetch all image URLs the moment their list items mount — even off-screen ones. That's a bandwidth catastrophe on a page with 200+ products.
Second: is your API response paginated at a sane size? 24–48 items per page is the sweet spot for most grids. Below 10, you trigger fetches constantly. Above 100, the first paint is slow and the JSON payload is huge. If you're on a GraphQL backend, keep an eye on the react-query-vs-swr article for notes on cache key strategies with Apollo.
Third: are you deduplicating React Query cache keys correctly? If your query key is ['products'] and you also use that key for a "featured products" widget elsewhere, you'll get cache collisions. Use ['products', 'feed'] for the infinite list and ['products', 'featured'] for the widget.
Fourth: run a Lighthouse performance audit after implementing. The lighthouse-performance-audit guide covers the specific metrics — look at TBT (Total Blocking Time) and LCP. An infinite scroll feed that loads 48 large images on first render will tank both. Virtualization, lazy images, and a reasonable rootMargin on your sentinel are what bring those numbers back. If you want to see how a polished React UI with heavy component lists can stay fast, browse the Empire UI component library — it renders hundreds of interactive previews on a single page without breaking a sweat.
FAQ
Intersection Observer, always. Scroll events fire hundreds of times per second and require debouncing that either wastes CPU or hurts responsiveness. Intersection Observer runs off the main thread and only fires when the sentinel actually enters the viewport.
Yes — that's what it's designed for. Return the next cursor from your API, pass it to getNextPageParam, and React Query handles the rest. It works equally well with page-number pagination if your API uses that instead.
Once your list can plausibly reach 500+ rendered items. Below that, the DOM cost is manageable. Above it — especially on mobile or with image-heavy cards — virtualization becomes necessary to maintain 60fps scroll.
Save window.scrollY to sessionStorage before navigation and restore it on mount. For TanStack Virtual, also encode the current page count in the URL so you can re-hydrate the correct number of pages before restoring scroll.