Type something to search...
Implementing Infinite Scroll in React

Implementing Infinite Scroll in React

Infinite scroll looks simple: when the user nears the bottom of the list, load more. The first version usually listens to the scroll event, compares scrollTop to scrollHeight, and calls the API. Then the bugs arrive. Fast scrolling fires the same request three times. Items appear twice because new posts shifted the offsets. The footer is unreachable. After opening an item and pressing Back, the user lands at the top of a list they'd scrolled through for five minutes.

A solid implementation separates three concerns: detecting when to load more, fetching and storing pages without duplicates or races, and rendering a list that may grow to thousands of items. React gives you good tools for each: IntersectionObserver for detection, TanStack Query's useInfiniteQuery for pages, and virtualization for very long lists.

This guide builds infinite scroll step by step: a reusable intersection hook, a version with plain state, the production version with useInfiniteQuery and cursor pagination, virtualization, error handling, and the accessibility details that are easy to miss.

Detecting the End of the List With IntersectionObserver

Instead of listening to scroll events and doing math on every frame, place an invisible sentinel element after the last item and ask the browser to tell you when it becomes visible. IntersectionObserver does exactly that, off the main thread and without layout thrashing.

A small hook wraps it using a ref callback. React 19 lets ref callbacks return a cleanup function, which makes this neat:

// src/hooks/useOnVisible.ts
import { useCallback, useEffect, useRef } from "react";

type Options = {
  rootMargin?: string;
  enabled?: boolean;
};

export function useOnVisible<T extends Element>(
  onVisible: () => void,
  { rootMargin = "400px", enabled = true }: Options = {},
) {
  const callbackRef = useRef(onVisible);

  useEffect(() => {
    callbackRef.current = onVisible;
  });

  return useCallback(
    (node: T | null) => {
      if (!node || !enabled) return;

      const observer = new IntersectionObserver(
        (entries) => {
          if (entries.some((entry) => entry.isIntersecting)) {
            callbackRef.current();
          }
        },
        { rootMargin },
      );

      observer.observe(node);
      return () => observer.disconnect();
    },
    [rootMargin, enabled],
  );
}

A few details are doing real work here:

  • rootMargin: "400px" triggers the callback when the sentinel is still 400 pixels below the viewport, so the next page usually loads before the user reaches the end.
  • The callback lives in a ref, so the observer doesn't need to be recreated every render just because the handler changed.
  • enabled lets you turn observation off when there's nothing more to load or a request is in flight. Because it's in the useCallback dependencies, toggling it creates a new ref callback, which disconnects the old observer and creates a new one. That re-observation also fires the callback again if the sentinel is still visible, which is exactly what you want after a page loads but doesn't fill the screen.

If you'd rather use a library, react-intersection-observer provides a useInView hook that does the same thing.

A First Version With Plain State

Here's infinite scroll with nothing but React state and fetch, against an API that supports ?page= and returns { items, hasMore }:

import { useCallback, useState } from "react";
import { useOnVisible } from "../hooks/useOnVisible";

type Post = { id: number; title: string };
type PageResponse = { items: Post[]; hasMore: boolean };

export function SimpleFeed() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;
    setLoading(true);
    setError(null);
    try {
      const res = await fetch(`/api/posts?page=${page}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const data: PageResponse = await res.json();
      setPosts((prev) => [...prev, ...data.items]);
      setHasMore(data.hasMore);
      setPage((p) => p + 1);
    } catch (e) {
      setError((e as Error).message);
    } finally {
      setLoading(false);
    }
  }, [page, loading, hasMore]);

  const sentinelRef = useOnVisible<HTMLDivElement>(loadMore, {
    enabled: hasMore && !loading && !error,
  });

  return (
    <section aria-label="Posts">
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      {error && (
        <p role="alert">
          Couldn't load more posts. <button onClick={loadMore}>Retry</button>
        </p>
      )}
      {loading && <p role="status">Loading more posts…</p>}
      {hasMore ? <div ref={sentinelRef} /> : <p>You've reached the end.</p>}
    </section>
  );
}

This works and is a reasonable choice for a small widget. The loading guard prevents duplicate requests, and disabling the observer while an error is shown avoids hammering a failing API. But it has real limitations:

  • Navigating away and back throws all pages away.
  • Nothing cancels in-flight requests if the component unmounts.
  • Page-number pagination can produce duplicates or gaps when items are inserted or deleted while the user scrolls.
  • Every list in the app would need to repeat this state machine.

Production Version With useInfiniteQuery

TanStack Query's useInfiniteQuery manages a list of pages, knows how to request the next one, deduplicates calls, caches results across navigation, and exposes the status flags you need.

Use Cursor Pagination

First, prefer cursor-based pagination for feeds. Instead of "give me page 3", the client says "give me 20 items after the item with ID abc123". New items inserted at the top don't shift what comes next, so you never see duplicates or skip items. A typical response:

{
  "items": [{ "id": "p_201", "title": "Shipping notes" }],
  "nextCursor": "p_181"
}

nextCursor is null on the last page. Pagination strategies for React applications compares offset and cursor pagination in more detail.

The Query

// src/features/feed/api.ts
export type Post = { id: string; title: string; excerpt: string };
export type FeedPage = { items: Post[]; nextCursor: string | null };

export async function fetchFeed(cursor: string | null, signal: AbortSignal): Promise<FeedPage> {
  const params = new URLSearchParams({ limit: "20" });
  if (cursor) params.set("cursor", cursor);

  const res = await fetch(`/api/feed?${params}`, { signal });
  if (!res.ok) throw new Error(`Failed to load feed (${res.status})`);
  return res.json();
}
// src/features/feed/Feed.tsx
import { useInfiniteQuery } from "@tanstack/react-query";
import { useOnVisible } from "../../hooks/useOnVisible";
import { fetchFeed } from "./api";

export function Feed() {
  const {
    data,
    status,
    error,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isFetchNextPageError,
  } = useInfiniteQuery({
    queryKey: ["feed"],
    queryFn: ({ pageParam, signal }) => fetchFeed(pageParam, signal),
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });

  const sentinelRef = useOnVisible<HTMLDivElement>(() => fetchNextPage(), {
    enabled: hasNextPage && !isFetchingNextPage && !isFetchNextPageError,
  });

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

  const posts = data.pages.flatMap((page) => page.items);

  return (
    <section aria-label="Feed">
      <ul className="feed">
        {posts.map((post) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.excerpt}</p>
          </li>
        ))}
      </ul>

      <div ref={sentinelRef} aria-hidden="true" />

      <div role="status" aria-live="polite">
        {isFetchingNextPage && "Loading more posts…"}
      </div>

      {isFetchNextPageError && (
        <div role="alert">
          <p>Couldn't load more posts.</p>
          <button type="button" onClick={() => fetchNextPage()}>
            Try again
          </button>
        </div>
      )}

      {!hasNextPage && <p>You've reached the end.</p>}
    </section>
  );
}

function FeedSkeleton() {
  return <p role="status">Loading feed…</p>;
}

How the pieces fit:

  • initialPageParam is the cursor for the first page. It's required in TanStack Query v5.
  • getNextPageParam reads the cursor from the last page. Returning null or undefined sets hasNextPage to false.
  • data.pages is an array of every loaded page. flatMap turns it into one list.
  • fetchNextPage is not deduplicated for you. By default, calling it while a fetch is already running cancels that fetch and starts a new one. That's why the sentinel is disabled while isFetchingNextPage is true, so a sentinel firing twice can't restart the request.
  • isFetchNextPageError distinguishes "the next page failed" from "the first page failed". Existing items stay on screen and the user gets a retry button.
  • The query function receives a signal, so unmounting cancels the request.

Because the result is cached under ["feed"], navigating to a post and pressing Back restores every loaded page instantly instead of starting over. For the fundamentals of query keys and caching, see managing server state with TanStack Query.

Limiting Memory With maxPages

An endless feed can accumulate hundreds of pages in memory and refetch all of them when the query becomes stale. The maxPages option caps how many pages are kept:

useInfiniteQuery({
  queryKey: ["feed"],
  queryFn: ({ pageParam, signal }) => fetchFeed(pageParam, signal),
  initialPageParam: null as string | null,
  getNextPageParam: (lastPage) => lastPage.nextCursor,
  maxPages: 10,
});

When the limit is reached, the oldest page is dropped from the start. To let users scroll back up into dropped pages, you also need getPreviousPageParam and a sentinel at the top that calls fetchPreviousPage, which requires your API to support backward cursors.

Virtualizing Long Lists

Every loaded item stays in the DOM. After a few thousand cards with images, scrolling gets sluggish and memory climbs. Virtualization renders only the items in or near the viewport, and adds spacing to keep the scrollbar accurate. TanStack Virtual pairs naturally with useInfiniteQuery:

npm install @tanstack/react-virtual
import { useEffect, useRef } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import { fetchFeed } from "./api";

export function VirtualFeed() {
  const parentRef = useRef<HTMLDivElement>(null);

  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
    queryKey: ["feed"],
    queryFn: ({ pageParam, signal }) => fetchFeed(pageParam, signal),
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });

  const posts = data?.pages.flatMap((p) => p.items) ?? [];
  const rowCount = hasNextPage ? posts.length + 1 : posts.length;

  const virtualizer = useVirtualizer({
    count: rowCount,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 96,
    overscan: 5,
  });

  const items = virtualizer.getVirtualItems();
  const lastIndex = items.at(-1)?.index ?? -1;

  useEffect(() => {
    if (lastIndex >= posts.length - 1 && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [lastIndex, posts.length, hasNextPage, isFetchingNextPage, fetchNextPage]);

  return (
    <div ref={parentRef} style={{ height: "80vh", overflowY: "auto" }}>
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {items.map((row) => {
          const post = posts[row.index];
          return (
            <div
              key={row.key}
              data-index={row.index}
              ref={virtualizer.measureElement}
              style={{
                position: "absolute",
                top: 0,
                left: 0,
                width: "100%",
                transform: `translateY(${row.start}px)`,
              }}
            >
              {post ? (
                <article>
                  <h2>{post.title}</h2>
                  <p>{post.excerpt}</p>
                </article>
              ) : (
                <p>Loading more…</p>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

With virtualization you don't need a sentinel. The list knows which index is rendered, and when the last visible row is the loader row, it fetches the next page. measureElement handles rows with variable heights. The trade-off is that browser find-in-page can't see rows that aren't rendered. Virtualizing long lists with TanStack Virtual covers measurement and window scrolling in depth.

Only virtualize when you need to. A few hundred simple items render fine without it.

Scroll Restoration

When users open an item and come back, they expect to land where they left off. Two things are needed: the data must still be there, and the scroll position must be restored.

The cache from useInfiniteQuery handles the first part. For window scrolling in React Router v7, rendering ScrollRestoration in your root layout restores the position on Back navigation:

import { Outlet, ScrollRestoration } from "react-router";

export function RootLayout() {
  return (
    <>
      <Outlet />
      <ScrollRestoration />
    </>
  );
}

This works because the cached pages render at full height immediately, before the browser applies the saved position. If your list scrolls inside a container rather than the window, save the container's scrollTop in sessionStorage on unmount and restore it after the first render. For virtualized lists, TanStack Virtual's initialOffset option sets the starting scroll offset.

Accessibility and UX Considerations

Infinite scroll has real accessibility costs, so handle these deliberately:

  • Announce new content. A polite live region that says "Loading more posts…" and "20 more posts loaded" tells screen reader users that the list grew.
  • Keep the footer reachable. If content loads forever, users can never reach links in the footer. Move important links elsewhere, or stop auto-loading after a few pages and show a "Load more" button.
  • Offer a button fallback. A visible "Load more" button works for keyboard users, screen reader users, and anyone who prefers control. Many sites auto-load the first few pages, then switch to the button.
  • Preserve focus. When new items load, don't move focus. Keyboard users continue tabbing from where they were.
  • Use list semantics. A real ul with li items lets screen readers announce the item count and position.
  • Consider whether you need it at all. For search results or anything users compare or return to, numbered pagination is often better. Infinite scroll suits feeds where browsing, not finding, is the goal.

Common Mistakes With Infinite Scroll

  • Using scroll event listeners. They fire constantly and require manual throttling. IntersectionObserver is cheaper and simpler.
  • Not guarding against duplicate requests. Without a loading check, a sentinel that stays visible triggers repeated fetches.
  • Offset pagination on changing data. Inserts and deletes shift offsets, causing duplicates and gaps. Use cursors.
  • Using array index as the key. Items can shift when pages refetch. Use a stable ID from the data.
  • Retrying errors automatically in a loop. If the next page fails, stop observing and show a retry button.
  • Never stopping. Thousands of DOM nodes slow down the page. Virtualize, cap pages with maxPages, or switch to a button.
  • Losing everything on Back. Keep pages in a cache and restore scroll position.

Frequently Asked Questions (FAQ) About Infinite Scroll in React

Use IntersectionObserver. It tells you when an element enters the viewport without running code on every scroll frame, and it supports a root margin so you can start loading before the user reaches the end. Scroll listeners need manual throttling and layout calculations, which are easy to get wrong.

useQuery stores one result per key. useInfiniteQuery stores an ordered list of pages under one key and adds fetchNextPage, hasNextPage, and page parameter handling. When it refetches, it refetches pages in order so cursors stay consistent.

Usually because the API uses offset pagination and items were added or removed between requests, shifting what each page contains. Switching to cursor pagination fixes it. Duplicate requests from a sentinel firing several times can also cause it, which a guard on the loading flag prevents.

Content loaded only by scrolling may not be discovered by search engines. If the pages matter for search, also expose paginated URLs with real links, or server-render the first page and provide a crawlable link to the next one. For logged-in feeds, SEO usually doesn't matter.

When the number of rendered items gets large enough to slow scrolling or use too much memory, typically somewhere in the high hundreds to thousands depending on item complexity. Measure with the React DevTools Profiler and browser performance tools before adding it, since virtualization adds complexity.

Call refetch on the infinite query, which reloads pages in order starting from the first. For a new posts banner, poll a lightweight endpoint for the newest ID and, when the user clicks the banner, reset the query so it starts again from the top.

Conclusion

Reliable infinite scroll in React comes from splitting the problem in three. An IntersectionObserver sentinel with a generous root margin detects when to load more. useInfiniteQuery with cursor pagination fetches pages without duplicates or races, caches them across navigation, and exposes clear flags for loading and errors. Virtualization keeps very long lists fast. Around that core, a polite live region, a retry button, a "Load more" fallback, and scroll restoration make the feature usable for everyone.

Start with the useOnVisible hook and useInfiniteQuery version from this guide, pointed at a cursor-based endpoint. Test it with a throttled network and a failing API to check that loading and error states behave. Add maxPages or virtualization only once you've measured that the list grows large enough to need it.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading