Back to React
React
easy
mid

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.

9 min read·~25 min to think through

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

tsx
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

  1. Cursor-based pagination — each page returns nextCursor; getNextPageParam returns null when done.
  2. AbortController — TanStack Query passes signal to queryFn; aborts on unmount or query-key change.
  3. Loading / error / empty / end states — explicit branches in the UI.
  4. Retryrefetch on error fallback.
  5. Sentinel-based trigger — IntersectionObserver fires once when the sentinel enters viewport; guarded by hasNextPage and !isFetchingNextPage to avoid double-fire.
  6. rootMargin — fetches early so the user doesn't see a loading flash.
  7. Cleanup — observer disconnected on unmount.

Without TanStack Query (hand-rolled)

tsx
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:

tsx
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:

tsx
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-live for 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.

Senior engineer discussion

Seniors reach for useInfiniteQuery + IntersectionObserver as the standard pattern, add virtualization at scale, handle all four UI states explicitly, and design for SEO (paginated URLs, initial SSR) when discoverability matters.

Related questions