Building Performant Infinite Scroll in React: Patterns, Pitfalls, and Production Tips

Build performant React infinite scroll using IntersectionObserver, React Query, and virtualization, with production tips and code examples.

ASOasis
8 min read
Building Performant Infinite Scroll in React: Patterns, Pitfalls, and Production Tips

Image used for representation purposes only.

Overview

Infinite scroll can deliver a fluid, app‑like browsing experience by loading more content as the user nears the end of a list. Done poorly, it causes jank, duplicate requests, and broken back/forward navigation. This guide walks through robust React patterns—from a minimal vanilla implementation to production‑grade solutions with React Query and list virtualization—plus performance, accessibility, and testing tips.

When to use infinite scroll (and when not to)

Use infinite scroll when:

  • Users are exploring large, homogeneous feeds (activity streams, catalogs, timelines).
  • There’s no strong need to jump to a specific page index.

Prefer classic pagination or a “Load more” button when:

  • Users need positional context (e.g., page 7 of 20) or deep linking to a page.
  • Content is heterogeneous or task‑oriented (e.g., search results where users compare across pages).

A hybrid works well: infinite scroll with a visible “Load more” control and URL state for restoration.

Data modeling: offset vs cursor

  • Offset/page-based (page=3, limit=20): simple, but fragile if items are inserted/removed server‑side while scrolling.
  • Cursor-based (after=cursor123): robust to data churn and preferred at scale. Your API returns items plus a nextCursor (or null when done).

Core building blocks

  • IntersectionObserver (IO): Efficiently detect when a sentinel element is near the viewport, triggering the next fetch. Fewer scroll event pitfalls, better battery life.
  • AbortController: Cancel stale fetches to avoid races and wasted network.
  • Virtualization: Render only what’s visible to keep DOM light. Libraries like react-window or @tanstack/react-virtual prevent slow rendering with thousands of nodes.
  • Cache and de‑dupe: Prevent duplicates when multiple requests overlap or filters change.

A minimal, no-library example

This example uses IntersectionObserver and fetch with an AbortController. It assumes a cursor‑based API at /api/items?after=cursor.

import React, { useCallback, useEffect, useRef, useState } from 'react';

type Item = { id: string; title: string };
type Page = { items: Item[]; nextCursor: string | null };

function useIntersection(
  onIntersect: () => void,
  { root = null, rootMargin = '600px 0px', threshold = 0, enabled = true } = {}
) {
  const ref = useRef<HTMLDivElement | null>(null);
  const onIntersectRef = useRef(onIntersect);
  onIntersectRef.current = onIntersect;

  useEffect(() => {
    if (!enabled || !ref.current) return;
    const el = ref.current;
    const observer = new IntersectionObserver((entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) onIntersectRef.current();
      });
    }, { root, rootMargin, threshold });

    observer.observe(el);
    return () => observer.unobserve(el);
  }, [root, rootMargin, threshold, enabled]);

  return ref;
}

export default function InfiniteList() {
  const [items, setItems] = useState<Item[]>([]);
  const [cursor, setCursor] = useState<string | null>(null);
  const [hasMore, setHasMore] = useState(true);
  const [loading, setLoading] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  const load = useCallback(async () => {
    if (loading || !hasMore) return;
    setLoading(true);

    abortRef.current?.abort();
    const ac = new AbortController();
    abortRef.current = ac;

    const url = new URL('/api/items', window.location.origin);
    if (cursor) url.searchParams.set('after', cursor);

    try {
      const res = await fetch(url.toString(), { signal: ac.signal });
      if (!res.ok) throw new Error('Network error');
      const page: Page = await res.json();

      // De-dup by id if server can return overlaps
      setItems((prev) => {
        const seen = new Set(prev.map((i) => i.id));
        const merged = [...prev];
        for (const i of page.items) if (!seen.has(i.id)) merged.push(i);
        return merged;
      });

      setCursor(page.nextCursor);
      setHasMore(Boolean(page.nextCursor));
    } catch (e) {
      if ((e as any).name !== 'AbortError') console.error(e);
    } finally {
      setLoading(false);
    }
  }, [cursor, hasMore, loading]);

  // Initial load
  useEffect(() => {
    load();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  const sentinelRef = useIntersection(load, { enabled: hasMore && !loading });

  return (
    <div className="feed">
      {items.map((it) => (
        <article key={it.id} className="card">{it.title}</article>
      ))}
      {hasMore && (
        <div ref={sentinelRef} aria-hidden style={{ height: 1 }} />
      )}
      {loading && <p role="status">Loading</p>}
      {!hasMore && <p>End of results</p>}
    </div>
  );
}

Key ideas:

  • rootMargin pulls the trigger before the user actually hits the bottom, hiding latency.
  • Abort previous fetches to avoid out‑of‑order races when users scroll rapidly or change filters.

Production‑grade data fetching with React Query

React Query’s useInfiniteQuery makes pagination state, caching, and dedupe simple.

import { useInfiniteQuery } from '@tanstack/react-query';

type Item = { id: string; title: string };
type Page = { items: Item[]; nextCursor: string | null };

function useItems(filters: Record<string, string>) {
  return useInfiniteQuery<Page, Error>({
    queryKey: ['items', filters],
    queryFn: async ({ pageParam }) => {
      const url = new URL('/api/items', window.location.origin);
      if (pageParam) url.searchParams.set('after', pageParam);
      for (const [k, v] of Object.entries(filters)) url.searchParams.set(k, v);
      const res = await fetch(url); if (!res.ok) throw new Error('Network');
      return res.json();
    },
    initialPageParam: null as string | null,
    getNextPageParam: (last) => last.nextCursor,
    staleTime: 30_000,
    gcTime: 5 * 60_000,
  });
}

function Feed({ filters = {} }) {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status, error } = useItems(filters);

  const items = data?.pages.flatMap((p) => p.items) ?? [];
  const onIntersect = () => { if (hasNextPage && !isFetchingNextPage) fetchNextPage(); };
  const sentinelRef = useIntersection(onIntersect, { enabled: hasNextPage && !isFetchingNextPage });

  if (status === 'pending') return <p>Loading</p>;
  if (status === 'error') return <p role="alert">{error.message}</p>;

  return (
    <div>
      {items.map((i) => <article key={i.id}>{i.title}</article>)}
      {hasNextPage && <div ref={sentinelRef} style={{ height: 1 }} />}
      {isFetchingNextPage && <p role="status">Loading more</p>}
    </div>
  );
}

Benefits:

  • Automatic cache de‑dupe across navigations.
  • getNextPageParam centralizes cursor logic.
  • hasNextPage and isFetchingNextPage simplify UI state.

Virtualization for large feeds

Infinite scroll without virtualization will eventually render thousands of DOM nodes—bad for performance and memory. With react-window + react-window-infinite-loader:

import { FixedSizeList as List, ListChildComponentProps } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';

function VirtualizedFeed({ items, loadMore, hasMore }: {
  items: { id: string; title: string }[];
  loadMore: () => void;
  hasMore: boolean;
}) {
  const itemCount = hasMore ? items.length + 1 : items.length;
  const isItemLoaded = (index: number) => index < items.length;

  const Row = ({ index, style }: ListChildComponentProps) => {
    if (!isItemLoaded(index)) {
      loadMore();
      return <div style={style}>Loading</div>;
    }
    const it = items[index];
    return <div style={style}><article>{it.title}</article></div>;
  };

  return (
    <InfiniteLoader
      isItemLoaded={isItemLoaded}
      itemCount={itemCount}
      loadMoreItems={async () => loadMore()}
      threshold={3}
    >
      {({ onItemsRendered, ref }) => (
        <List
          height={600}
          itemCount={itemCount}
          itemSize={88}
          width={800}
          onItemsRendered={onItemsRendered}
          ref={ref}
        >
          {Row}
        </List>
      )}
    </InfiniteLoader>
  );
}

Notes:

  • itemCount includes a trailing loader row while hasMore is true.
  • threshold prefetches before the very end.
  • If rows vary in size, use VariableSizeList and measure row heights or use @tanstack/react-virtual for a modern API and smooth dynamic heights.

Accessibility and UX

  • Maintain a visible “Load more” button as a progressive enhancement. Trigger it via IO programmatically, but keep it keyboard and screen‑reader friendly.
  • Announce dynamic updates with an ARIA live region:
    <p aria-live="polite" aria-atomic="true" id="feed-status" />
    
    Update its text (e.g., “Loaded 20 more items”).
  • Preserve focus: avoid programmatically shifting focus when new items append. If you do, move focus to the first newly loaded item only when the user requested “Load more”.
  • Prevent content jumps by reserving space for images (width/height or CSS aspect-ratio) and using lazy loading (loading=“lazy”).

State, URL, and scroll restoration

  • Put filters and lightweight cursor info in the URL (query params) so back/forward restores context.
  • Cache pages in React Query; on return, rehydrate and call window.scrollTo(savedY).
  • In React Router, consider ScrollRestoration or implement a custom hook to store scrollTop in history.state during unmount.
useEffect(() => {
  const y = sessionStorage.getItem('feedY');
  if (y) window.scrollTo(0, Number(y));
  return () => sessionStorage.setItem('feedY', String(window.scrollY));
}, []);

Error handling and retries

  • Show inline error rows (“Couldn’t load more. Retry”) and allow manual retry.
  • Apply exponential backoff on transient 5xx or network errors.
  • Guard against duplicates by id and ensure stable React keys.

Performance checklist

  • Fetch early: set rootMargin to 300–800px depending on item height and network RTT.
  • Avoid re‑creating observers; memoize the callback or keep it in a ref (as shown).
  • Batch state updates (setItems with functional updates).
  • Image hygiene: lazy loading, responsive sizes (srcset, sizes), and placeholders.
  • Virtualize once you exceed a few hundred items in the DOM.
  • Keep item components pure; memoize expensive children.
  • Prefer cursor pagination to minimize over-fetch and race conditions.

Container vs window scrolling

If your list scrolls inside a container (not window):

  • Pass that container as the intersection root and ensure it has position and overflow set.
  • Place the sentinel inside the scrolling container.
  • Beware sticky headers reducing the effective viewport; adjust rootMargin accordingly (e.g., rootMargin: '600px 0px 200px 0px').

SSR and Next.js considerations

  • IO runs only in the browser. Gate logic behind typeof window !== 'undefined' if needed.
  • Pre-render the first page on the server for SEO and speed, then hydrate and continue with infinite loading client‑side.
  • For image-heavy feeds, use Next.js Image for optimized loading and dimension stability.

Testing strategies

  • Unit test the pagination reducer/merger to guarantee no duplicates.
  • Integration test with MSW: mock paged endpoints, assert that scroll (or intersection simulation) triggers the next request and the DOM grows with correct items.
  • E2E with Playwright: scroll to bottom, verify more items render, simulate network errors, and confirm the retry path.

Common pitfalls to avoid

  • Forgetting to unobserve the sentinel when loading or when the list ends (causes repeated triggers).
  • Triggering on every minor intersection because threshold is too high or sentinel is too tall.
  • Relying on scroll events with tight throttling, causing missed loads on fast scrolls. Prefer IO.
  • Not accounting for iOS rubber‑banding/overscroll; keep a comfortable rootMargin.
  • Appending HTML without reserving space for media, causing layout shifts and a “bouncy” experience.

Putting it all together

For most apps, the sweet spot is:

  • Cursor‑based API.
  • React Query’s useInfiniteQuery to manage pages and caching.
  • IntersectionObserver sentinel with generous rootMargin.
  • Virtualization (react-window or @tanstack/react-virtual) once the DOM grows.
  • A visible, accessible “Load more” fallback and robust error handling.

With these pieces, your React infinite scroll will feel instant, scale to huge lists, and remain resilient to real‑world network and UX challenges.

Related Posts