How would you implement an infinite scroll component in React with data fetching and error handling?
Combine TanStack Query's useInfiniteQuery (paginated fetching, cursors, dedup, retry) with IntersectionObserver on a sentinel near the list bottom. Show loading sentinel, handle error with retry button, handle empty + end-of-list. For massive datasets pair with virtualization (TanStack Virtual). Make sure cleanup observers on unmount, AbortController on stale fetches, and the IO doesn't double-fire when pages arrive.
Production-grade infinite scroll has more parts than it looks: data fetching with cursors, intersection sentinel, error + retry, end-of-list, optional virtualization.
With TanStack Query
import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
type Page = { items: Item[]; nextCursor: string | null };
export function InfiniteFeed() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
error,
refetch,
} = useInfiniteQuery<Page>({
queryKey: ['feed'],
queryFn: async ({ pageParam, signal }) => {
const res = await fetch(`/api/feed?cursor=${pageParam ?? ''}`, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
initialPageParam: null,
getNextPageParam: (last) => last.nextCursor,
});
const items = data?.pages.flatMap(p => p.items) ?? [];
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ rootMargin: '200px' } // start fetching before sentinel is fully visible
);
io.observe(el);
return () => io.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (status === 'pending') return <Skeleton />;
if (status === 'error') return <Error onRetry={refetch} message={error.message} />;
if (items.length === 0) return <Empty />;
return (
<div>
<ul>
{items.map(item => <Item key={item.id} item={item} />)}
</ul>
<div ref={sentinelRef} />
{isFetchingNextPage && <Spinner />}
{!hasNextPage && <EndOfList />}
</div>
);
}What this handles
- Cursor-based pagination — each page returns
nextCursor;getNextPageParamreturns null when done. - AbortController — TanStack Query passes
signaltoqueryFn; aborts on unmount or query-key change. - Loading / error / empty / end states — explicit branches in the UI.
- Retry —
refetchon error fallback. - Sentinel-based trigger — IntersectionObserver fires once when the sentinel enters viewport; guarded by
hasNextPageand!isFetchingNextPageto avoid double-fire. - rootMargin — fetches early so the user doesn't see a loading flash.
- Cleanup — observer disconnected on unmount.
Without TanStack Query (hand-rolled)
function InfiniteFeed() {
const [pages, setPages] = useState<Page[]>([]);
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
const sentinelRef = useRef<HTMLDivElement>(null);
async function loadMore() {
if (status === 'loading' || status === 'done') return;
setStatus('loading');
const cursor = pages.at(-1)?.nextCursor ?? '';
try {
const res = await fetch(`/api/feed?cursor=${cursor}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const page: Page = await res.json();
setPages(p => [...p, page]);
setStatus(page.nextCursor ? 'idle' : 'done');
} catch {
setStatus('error');
}
}
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const io = new IntersectionObserver(([e]) => e.isIntersecting && loadMore(), { rootMargin: '200px' });
io.observe(el);
return () => io.disconnect();
}, [pages]);
// … render
}Works, but you'll re-implement (badly): dedup, retry, refetch on focus, cache, AbortController. Use a library.
Virtualization for huge lists
If pages accumulate to thousands of items, the DOM gets heavy. Combine with TanStack Virtual:
const v = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80,
overscan: 5,
});
// Inside the parent scroll container
{v.getVirtualItems().map(row => (
<div
key={row.key}
style={{ position: 'absolute', top: 0, transform: `translateY(${row.start}px)`, width: '100%' }}
>
<Item item={items[row.index]} />
</div>
))}Trigger fetchNextPage when the virtual rows near the end:
useEffect(() => {
const last = v.getVirtualItems().at(-1);
if (last && last.index >= items.length - 10 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [v.getVirtualItems(), items.length, hasNextPage, isFetchingNextPage]);Accessibility
- The sentinel is invisible; screen reader announcements come from the appended items.
aria-livefor status updates ("loading more…", "no more items").- Keyboard pagination button as alternative to scroll-based trigger.
Edge cases
- Fast scroll past the sentinel before fetch completes — TanStack handles dedup; if hand-rolled, guard with isFetchingNextPage.
- Network error mid-scroll — show inline retry button at the bottom, not blocking modal.
- Tab inactive — TanStack pauses refetch on focus; sentinel doesn't fire when not visible.
- Cursor invalidated server-side — handle 410/404 gracefully; reset to first page.
- Items get inserted/deleted from earlier pages — invalidate the query and refetch from scratch, or use optimistic updates.
Pitfalls
- Page-based pagination (
?page=2) vs cursor-based: cursor is robust to inserts/deletes. - No sentinel guard → page 2 fires twice while page 2 is loading.
- IntersectionObserver still observing on unmount → memory leak.
- Hand-rolled fetch with no AbortController → stale responses race.
- Always-loading state on long scroll → loading flash; rootMargin avoids it.
- No end-of-list UI → user keeps scrolling expecting more.
Mental model
Infinite scroll = paginated fetch + intersection trigger + four UI states (loading/error/empty/end). Use a library for the fetch layer. Add virtualization when item count grows. Handle a11y and edge cases explicitly.
Follow-up questions
- •Why cursor pagination over offset?
- •How do you handle items being inserted/deleted from earlier pages?
- •When do you need to combine with virtualization?
- •What are the SEO trade-offs of infinite scroll?
Common mistakes
- •No sentinel guard — fires multiple loads.
- •Page-based pagination — breaks on inserts/deletes.
- •Hand-rolling without AbortController — race conditions.
- •Missing end-of-list UI — user keeps scrolling.
- •Forgetting to disconnect observer on unmount.
- •No error retry — one failure ends the session.
Performance considerations
- •Without virtualization, infinite scroll grows DOM unbounded — eventual jank. With virtualization, scroll stays smooth even at 10k+ items. Cursor pagination is robust to data changes. rootMargin hides loading flash by prefetching early.
Edge cases
- •Items get inserted server-side mid-session — cursor handles, page-based doesn't.
- •User scrolls back up — virtualized items remount; preserve state via item keys.
- •Fast Ctrl+End to bottom — sentinel fires immediately; throttle if needed.
- •Background tab — IO doesn't fire; resume on focus.
- •Initial page should be SSR'd for SEO; subsequent pages client-side.
Real-world examples
- •Twitter / X timeline, Instagram feed, Reddit listing.
- •TanStack Query useInfiniteQuery is the standard React abstraction.
- •React Virtuoso has infinite scroll baked in.