Hazem Azzam

All posts
Writing

How to Build Infinite Scroll with React Query and IntersectionObserver

A from-scratch guide to infinite scroll in React: cursor pagination, useInfiniteQuery to accumulate pages, and an IntersectionObserver sentinel to load more — plus the pitfalls to avoid.

4 min read
reactreact-queryinfinite-scrollfrontendpagination

Why infinite scroll exists

Pagination with numbered pages is fine for a search results table, but it breaks down for media galleries, feeds, and image pickers — places where the user just wants to keep browsing without clicking "Next." Infinite scroll solves this: as the user nears the bottom of a list, the next batch loads automatically and appends to what's already there.

This post builds infinite scroll from first principles using React Query (useInfiniteQuery) and an IntersectionObserver sentinel. The same ideas port to any data-fetching library — the pattern is what matters.

We'll use a generic example throughout: a list of Item objects fetched from /api/items.

interface Item {
  id: string;
  title: string;
  thumbnailUrl: string;
}

Two ways to paginate: offset vs cursor

Before any UI, decide how the backend hands you "the next page."

Offset pagination uses ?page=2&limit=20 (or ?offset=40). It's simple, but it has a well-known flaw: if a row is inserted or deleted between requests, the offsets shift and you get duplicated or skipped items.

Cursor pagination returns an opaque pointer to "where you left off." Each response includes a nextCursor; you pass it back to fetch what comes after it.

{
  "items": [{ "id": "a1", "title": "First" }],
  "nextCursor": "eyJpZCI6ImExIn0="
}

When nextCursor is null, there are no more pages. Cursor pagination is the better fit for infinite scroll because the list is append-only from the user's perspective and stays stable under concurrent writes. We'll use it here.

Step 1 — the fetch function

Your fetch function takes a cursor (absent on the first call) and returns one page plus the cursor for the next one.

interface ItemsPage {
  items: Item[];
  nextCursor: string | null;
}

async function fetchItems(cursor: string | null): Promise<ItemsPage> {
  const params = new URLSearchParams({ limit: "20" });
  if (cursor) params.set("cursor", cursor);

  const res = await fetch(`/api/items?${params.toString()}`);
  if (!res.ok) throw new Error("Failed to load items");
  return res.json();
}

Note the cursor is only appended when present, so the first request is a clean /api/items?limit=20.

Step 2 — useInfiniteQuery

React Query's useInfiniteQuery is purpose-built for this. It stores an array of pages and knows how to ask for the next one.

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

function useItems() {
  return useInfiniteQuery({
    queryKey: ["items"],
    initialPageParam: null as string | null,
    queryFn: ({ pageParam }) => fetchItems(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });
}

Three pieces do the work:

  • initialPageParam — the cursor for the first fetch (null = start from the beginning).
  • queryFn receives pageParam (the current cursor) and returns a page.
  • getNextPageParam reads nextCursor off the last page. Returning a falsy value tells React Query there's nothing more — it flips hasNextPage to false.

The hook hands back everything you need:

const {
  data,              // { pages: ItemsPage[], pageParams: [...] }
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage,
  isPending,
  isError,
} = useItems();

Because the data is stored as discrete pages, you flatten them for rendering:

const items = data?.pages.flatMap((page) => page.items) ?? [];

Step 3 — the IntersectionObserver sentinel

Now the actual "infinite" part. The classic naive approach listens to the scroll event and compares scrollTop against scrollHeight. Don't do that — scroll events fire dozens of times per second and force you into manual throttling.

IntersectionObserver is the modern answer. You place an invisible sentinel element at the end of the list; the browser tells you, off the main thread, the moment it scrolls into view. That's your cue to load more.

Wrap it in a small reusable hook:

import { useEffect, useRef } from "react";

function useInfiniteScroll(
  hasNextPage: boolean,
  isFetching: boolean,
  onLoadMore: () => void,
) {
  const ref = useRef<HTMLDivElement>(null);

  // Keep the latest callback without re-subscribing the observer.
  const onLoadMoreRef = useRef(onLoadMore);
  onLoadMoreRef.current = onLoadMore;

  useEffect(() => {
    const node = ref.current;
    if (!node || !hasNextPage || isFetching) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0]?.isIntersecting) onLoadMoreRef.current();
      },
      { rootMargin: "200px" },
    );

    observer.observe(node);
    return () => observer.disconnect();
  }, [hasNextPage, isFetching]);

  return ref;
}

Two details earn their keep:

  • rootMargin: "200px" triggers the load before the sentinel is actually visible — the next page is often ready by the time the user reaches the bottom, so scrolling feels seamless.
  • The callback ref. The effect only re-runs on [hasNextPage, isFetching], but onLoadMore may change every render. Storing it in a ref means the observer always calls the freshest version without tearing down and rebuilding on each render — a common source of stale-closure bugs.

Step 4 — wire it into the component

function ItemList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isPending,
    isError,
  } = useItems();

  const sentinelRef = useInfiniteScroll(
    Boolean(hasNextPage),
    isFetchingNextPage,
    () => void fetchNextPage(),
  );

  if (isPending) return <p>Loading…</p>;
  if (isError) return <p>Something went wrong.</p>;

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

  return (
    <div className="grid grid-cols-3 gap-3">
      {items.map((item) => (
        <figure key={item.id}>
          <img src={item.thumbnailUrl} alt={item.title} loading="lazy" />
          <figcaption>{item.title}</figcaption>
        </figure>
      ))}

      {hasNextPage && <div ref={sentinelRef} aria-hidden />}
      {isFetchingNextPage && <p>Loading more…</p>}
    </div>
  );
}

Notice the sentinel is only rendered while hasNextPage is true. Once the last page arrives, it disappears, the observer disconnects, and the list quietly stops growing.

Common pitfalls

Duplicate keys. If two pages can contain the same item (often a sign of offset pagination racing with inserts), React will warn about duplicate keys. Cursor pagination avoids this; if you're stuck on offsets, dedupe by id before rendering.

The sentinel never leaves the viewport. On very tall screens with few items, one page may not fill the scroll area, so the sentinel stays visible and you load page after page in a burst. The hasNextPage guard handles the end of the list, but to avoid a thundering load, ensure your page size comfortably fills a screen, or only render the sentinel after the first page settles.

Re-fetching everything after a mutation. If you add an item and call a blanket refetch, React Query refetches every loaded page. For large lists, prefer invalidating just the first page or optimistically prepending the new item to the cache.

Forgetting lazy loading on images. Infinite media grids can mount hundreds of <img> tags. loading="lazy" lets the browser defer off-screen images so the initial paint stays cheap.

Accessibility. Infinite scroll can trap keyboard and screen-reader users who can never reach a footer. Offer a visible "Load more" button as a fallback that calls the same fetchNextPage, and mark loading states with aria-live so assistive tech announces new content.

Wrapping up

The whole pattern is three moving parts: a cursor-based fetch, useInfiniteQuery to accumulate pages and track whether more exist, and an IntersectionObserver sentinel to fire fetchNextPage at the right moment. Keep the cursor logic on the server, render a flattened list, and guard the sentinel with hasNextPage — and you get a smooth, append-only experience that scales to thousands of items without numbered pages.


Rate this post

All fields are optional. Just stars is fine.

No ratings yet
How to Build Infinite Scroll with React Query and IntersectionObserver | Hazem Azzam