Infinite Query Patterns with TanStack Query: Pain Free Pagination
AI generated
</>
{ }
React · TanStack Query · Pagination
Infinite Query Patterns with TanStack Query
pain free pagination

Long lists that load more content on scroll are one of the most common UI requirements, but naive implementations regularly fail on duplicates, memory usage and inconsistent scroll position. TanStack Query's infinite query solves these problems with a clear page structure, cursor based navigation, and combines with virtualization to render even tens of thousands of items smoothly.

17 min read useInfiniteQuery · Cursor Pagination · Virtualization · Bidirectional React 19 · TanStack Query v5

1. Why naive pagination in React regularly fails

An infinite query sounds at first like a simple extension of useQuery: instead of a single page, you simply load several pages one after another. In practice, homegrown solutions regularly fail on the same three problems: duplicates when data shifts between two loads, uncontrolled growing memory usage for very long lists, and loss of scroll position on every refetch. These problems rarely show up in development with a handful of test records, only in production with real, constantly changing datasets.

TanStack Query's useInfiniteQuery solves exactly this class of problems by storing every loaded page as its own, addressable cache object, linked through an explicit cursor or page parameter. This article shows how an infinite query plays together with cursor pagination, bidirectional loading and virtualization to render lists with tens of thousands of items without performance loss.

2. useInfiniteQuery: basics and page structure

useInfiniteQuery differs from useQuery in one decisive way: the cache entry for an infinite query stores not a single data block, but an array of pages, each with its own metadata for the next and previous page. The queryFn receives a pageParam that determines which page is loaded, and the getNextPageParam function extracts the parameter for the next request from the most recently loaded page.

This explicit separation between page data and page metadata is the core reason an infinite query is more robust than a homegrown solution with a single, growing array. Every page stays individually identifiable, which allows refetching individual pages, targeted invalidation, and correct behavior on network errors for a single page, without affecting the pages already loaded successfully.


// useArticleFeed.ts — Basic infinite query with cursor-based pagination
import { useInfiniteQuery } from "@tanstack/react-query";

function useArticleFeed() {
  return useInfiniteQuery({
    queryKey: ["articles"],
    queryFn: ({ pageParam }) => fetchArticles({ cursor: pageParam, limit: 20 }),
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  });
}

// Component usage: pages is an array of page results, each with its own items
function ArticleFeed() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useArticleFeed();

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

  return (
    <div>
      {articles.map((a) => <ArticleCard key={a.id} article={a} />)}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? "Loading..." : "Load more"}
        </button>
      )}
    </div>
  );
}

3. Cursor vs. offset pagination in an infinite query context

The choice between cursor and offset pagination has direct consequences for the correctness of an infinite query. Offset pagination, where every page is requested via limit and offset, breaks as soon as new entries slot in before the current position between two loads: an entry shifts into an already loaded page, and on the next load it appears twice or is skipped entirely. In a feed with frequent new entries, this problem is not the exception but the normal case.

Cursor pagination fundamentally avoids this problem, because every page is requested through a stable reference point, such as the ID or timestamp of the previous page's last entry, instead of a numeric position. New entries before the cursor do not affect the already loaded pages of an infinite query, because the next request stays relative to the last known entry, not relative to the absolute position in the overall list. For any list that can change while it is being used, cursor pagination is therefore the more robust choice.

4. Bidirectional loading: paginating up and down

Some use cases, such as a chat history that initially opens in the middle of a conversation, or a feed with live updates at the top, need an infinite query that can load in both directions. TanStack Query supports this via getPreviousPageParam in addition to getNextPageParam, which makes fetchPreviousPage available as the counterpart to fetchNextPage, inserting new pages at the beginning instead of the end of the pages array.

The biggest challenge with bidirectional loading is not the data fetch itself, but preserving scroll position when new content is inserted at the top. Without a countermeasure, the visible viewport jumps upward as soon as the browser inserts new DOM elements above the current position. The common solution is to measure the current scroll height before inserting, and after rendering the new elements, add the difference to the new scroll height onto the scroll position, so the visible content stays stable.


// ChatHistory.tsx — Preserve scroll position when prepending older messages
function ChatHistory({ conversationId }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const { data, fetchPreviousPage, hasPreviousPage } = useMessagesInfiniteQuery(conversationId);

  const loadOlder = async () => {
    const container = containerRef.current;
    if (!container) return;

    const previousHeight = container.scrollHeight;
    await fetchPreviousPage();

    // Restore the visual position after new items were prepended
    requestAnimationFrame(() => {
      const heightDiff = container.scrollHeight - previousHeight;
      container.scrollTop += heightDiff;
    });
  };

  return (
    <div ref={containerRef} className="overflow-y-auto h-full">
      {hasPreviousPage && <button onClick={loadOlder}>Load older messages</button>}
      {data?.pages.flatMap((p) => p.messages).map((m) => <Message key={m.id} message={m} />)}
    </div>
  );
}

5. Load triggers: IntersectionObserver instead of scroll handlers

A common beginner mistake when building an infinite query is binding the load trigger to a scroll event handler that checks the scroll position on every pixel value. This approach fires dozens of times per second, blocks the main thread with expensive logic, and leads to janky scrolling, especially on lower powered devices. The more robust approach uses the IntersectionObserver API, which watches an invisible sentinel anchor at the end of the list and only fires once that anchor actually enters the visible viewport.

A sentinel element at the end of the list combined with IntersectionObserver fully decouples the loading logic from the scroll event and runs concurrently with rendering, without blocking the main thread. In React, this mechanism can be cleanly encapsulated in a reusable useIntersectionObserver hook that every infinite query component in the project can use, instead of reimplementing the observer logic in every list.

6. Virtualization: infinite query meets tens of thousands of rows

An infinite query alone solves the loading problem, but not the rendering problem: as soon as thousands of items sit in the DOM, scrolling becomes slow regardless of the data source, because the browser has to lay out and render every single DOM element. The solution is virtualization, for example with TanStack Virtual, which renders only the currently visible items plus a small buffer into the DOM and simulates the remaining space with simple spacers.

Combining infinite query and virtualization requires some care in integration: the virtualized container needs to know how many items exist in total, even if not all are loaded, so the scrollbar size is calculated correctly. A common solution is to have the server supply the total count and render placeholder items for not yet loaded ranges, which automatically trigger loading the corresponding page once reached.


// VirtualizedFeed.tsx — Combining useInfiniteQuery with TanStack Virtual
import { useVirtualizer } from "@tanstack/react-virtual";

function VirtualizedFeed() {
  const parentRef = useRef<HTMLDivElement>(null);
  const { data, fetchNextPage, hasNextPage } = useArticleFeed();
  const items = data?.pages.flatMap((p) => p.items) ?? [];

  const virtualizer = useVirtualizer({
    count: hasNextPage ? items.length + 1 : items.length, // +1 for loading row
    getScrollElement: () => parentRef.current,
    estimateSize: () => 96,
    overscan: 5,
  });

  useEffect(() => {
    const lastItem = virtualizer.getVirtualItems().at(-1);
    if (lastItem && lastItem.index >= items.length - 1 && hasNextPage) {
      fetchNextPage();
    }
  }, [virtualizer.getVirtualItems(), hasNextPage]);

  return (
    <div ref={parentRef} className="h-screen overflow-auto">
      <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
        {virtualizer.getVirtualItems().map((row) => (
          <div key={row.key} style={{ transform: `translateY(${row.start}px)` }}>
            {items[row.index] ? <ArticleCard article={items[row.index]} /> : "Loading..."}
          </div>
        ))}
      </div>
    </div>
  );
}

7. Invalidation and refetch for paginated data

Invalidating an infinite query, for example after creating a new record, requires care because by default all pages loaded so far are refetched. For a list with twenty loaded pages, a single invalidation triggers twenty parallel requests, which both loads the backend and wastes bandwidth unnecessarily. TanStack Query allows refetchType: "none" combined with targeted setQueryData to update only the relevant part of the already loaded pages, instead of reloading all of them.

For the common case where a new record should appear at the very top of the list, it is often simpler to insert the new item directly into the first page of the cache, instead of triggering a full invalidation. This targeted cache manipulation keeps the infinite query consistent, without users seeing a brief loading state across the entire, already scrolled list.

8. Error handling and retry per page

With an infinite query, a single page can fail without invalidating the pages already loaded successfully. TanStack Query treats an error during a fetch more separately from the overall state of the query: isFetchNextPageError indicates that specifically the last load attempt failed, while previous pages continue to display normally. This granularity allows a UI that shows an error with a retry button only at the end of the list, instead of replacing the entire view with a generic error message.

For unstable network connections, a slightly increased retry count specifically for load more operations, combined with exponential backoff via the retryDelay option, pays off. Since an infinite query is often used while scrolling on mobile devices with changing connection quality, this behavior considerably reduces visible errors, without retrying endlessly against a genuinely permanently down server.

9. Pagination strategies compared directly

The following table compares the common pagination approaches for React lists, focusing on consistency with changing data and suitability for infinite query patterns.

Approach Consistency with new data Jump to page N Suitability for infinite scroll
Offset pagination Prone to duplicates/gaps Simple Limited suitability
Cursor pagination Stable Hard without extra steps Very well suited
Keyset with timestamp Stable Hard Very good, even bidirectional
Classic page numbers Prone to issues Very simple Unsuitable

For classic, page based navigation with visible page numbers, offset pagination remains the simplest solution, provided the underlying data rarely changes. For every infinite query with load on scroll, cursor or keyset pagination is the more robust choice, because it stays consistent regardless of changes made to the dataset in the meantime.

Mironsoft

Performant lists and pagination architecture for React applications

Long lists that scroll smoothly even at 50,000 items?

We combine useInfiniteQuery with cursor pagination and virtualization so your feeds and tables stay performant regardless of data volume.

Pagination audit

Analysis of existing offset pagination for duplicates and inconsistencies

Virtualization

TanStack Virtual integration for lists with tens of thousands of rows

Bidirectional loading

Chat histories and feeds with stable scroll position in both directions

10. Summary

An infinite query with TanStack Query solves the typical pagination problems by storing every page as its own cache object, instead of maintaining a single, growing array. Cursor pagination stays consistent when data changes between two loads, while offset pagination tends toward duplicates or gaps in that scenario. Bidirectional loading via getPreviousPageParam requires extra care to preserve scroll position, but is indispensable for chat histories and live feeds.

IntersectionObserver instead of scroll handlers decouples loading from the main thread, and combining it with virtualization via TanStack Virtual makes an infinite query performant even with tens of thousands of items. Targeted cache updates instead of full invalidation avoid unnecessary requests for new records, and granular error handling per page prevents a single failed load more attempt from making the entire list unusable.

Infinite Query patterns with TanStack Query, the essentials at a glance

Page structure

Every page is its own cache object with getNextPageParam for stable navigation.

Cursor over offset

Stays consistent with changing data, avoiding duplicates and gaps.

Virtualization

TanStack Virtual renders only visible items, performant even with tens of thousands of rows.

Load triggers

IntersectionObserver instead of scroll handlers for smooth loading.

11. FAQ: Infinite Query patterns with TanStack Query

1What does an infinite query store in the cache?
An array of pages with their own metadata for next and previous page, instead of a single growing array.
2Why does offset pagination break?
Positions shift with new entries, leading to duplicates or skipped entries.
3When do I need bidirectional loading?
For chat histories or feeds with live updates at the top, via getPreviousPageParam.
4Prevent scroll jumps on prepend?
Measure scroll height beforehand, calculate the difference after rendering and add it to the scroll position.
5Why IntersectionObserver over scroll events?
Scroll events fire too often and block the main thread, IntersectionObserver only fires on actual visibility.
6Do I always need virtualization?
Not for a few hundred items. From several thousand DOM elements onward, it becomes necessary for smooth scrolling.
7How do I invalidate efficiently?
Use setQueryData to update just the affected page instead of triggering a complete reload.
8What happens on a failed page?
isFetchNextPageError shows the error in isolation, without invalidating already loaded pages.
9Is cursor pagination always better?
For infinite scroll, yes. For page number navigation with a jump to page N, offset stays simpler.
10Total height without loading all pages?
The server supplies the total count, placeholders for not yet loaded ranges automatically trigger loading.