EmpireUI
Get Pro
← Blog7 min read#react#tanstack-query#infinite-scroll

Infinite Query in React: TanStack Query Cursor Pagination

Learn how to implement cursor-based infinite scroll in React using TanStack Query v5 useInfiniteQuery. Real code, real patterns, no hand-waving.

Code editor showing React and TypeScript code with a dark theme

Why Offset Pagination Is Quietly Breaking Your App

Honestly, offset pagination is one of those things that looks fine on paper and falls apart the moment your dataset grows past a few thousand rows. You're skipping N rows on every request — and that skip operation doesn't come for free. Your database is still scanning all the rows it's skipping over, which means your API gets slower as your list gets longer.

Cursor-based pagination fixes this. Instead of saying "give me rows 500–520", you say "give me 20 rows after this specific cursor". The cursor is usually an opaque token or a record ID. The database can index-scan to that position in O(log n) instead of scanning everything before it.

If you're building a feed, a comment section, a product listing, or anything with "load more" behavior, cursor pagination is the right call. TanStack Query (formerly React Query) has first-class support for it through useInfiniteQuery. Let's build it properly.

Setting Up TanStack Query v5 for Infinite Scroll

TanStack Query v5 shipped some breaking changes compared to v4. The useInfiniteQuery API changed its options shape — getNextPageParam still exists, but initialPageParam is now required and the page parameter is passed differently. If you're on v4, some of this will look slightly off.

Install with npm install @tanstack/react-query@5 and wrap your app in a QueryClientProvider. That part hasn't changed. For TypeScript projects, you'll also want to set strict: true in your tsconfig — it catches a lot of issues with the generic types TanStack uses. Check out React TypeScript tips if you need help getting those types locked in.

Here's the minimal setup — a QueryClient with sensible defaults for infinite queries: ``tsx import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, // 5 minutes gcTime: 1000 * 60 * 10, // 10 minutes retry: 2, }, }, }); export function App({ children }: { children: React.ReactNode }) { return ( <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> ); } ``

The useInfiniteQuery Hook: A Real Example

Here's where it gets concrete. Let's say you have an API that returns paginated posts with a nextCursor field in the response. Your fetch function accepts a cursor parameter and returns { posts, nextCursor }. That's a typical cursor-pagination shape.

The useInfiniteQuery hook accumulates all pages in data.pages — an array of your API responses. You access items by flattening across pages. The getNextPageParam function extracts the cursor for the next request from the last page returned. Return undefined to signal there are no more pages.

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

type Post = { id: string; title: string; body: string };
type PostsResponse = { posts: Post[]; nextCursor: string | null };

async function fetchPosts({ pageParam }: { pageParam: string | null }) {
  const url = pageParam
    ? `/api/posts?cursor=${pageParam}&limit=20`
    : `/api/posts?limit=20`;

  const res = await fetch(url);
  if (!res.ok) throw new Error('Failed to fetch posts');
  return res.json() as Promise<PostsResponse>;
}

export function useInfinitePosts() {
  return useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    initialPageParam: null as string | null,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  });
}

Building the Infinite Scroll UI Component

The hook is the easy part. Wiring up the UI — specifically knowing when the user has scrolled near the bottom — is where most people reach for a third-party library. react-intersection-observer is the standard choice. You attach a ref to a sentinel element at the bottom of your list, and when it enters the viewport, you call fetchNextPage().

Keep your list items as lightweight as possible. Rendering 300 posts with complex card layouts will chew through your frame budget fast. If you're seeing jank, consider react-window or react-virtual for virtualization — though that's a separate topic. For lists under a few hundred items, a simple map over data.pages is totally fine.

import { useRef, useEffect } from 'react';
import { useInView } from 'react-intersection-observer';
import { useInfinitePosts } from './useInfinitePosts';

export function InfinitePostList() {
  const { ref, inView } = useInView({ threshold: 0.1 });
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
    error,
  } = useInfinitePosts();

  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

  if (status === 'pending') return <p>Loading...</p>;
  if (status === 'error') return <p>Error: {(error as Error).message}</p>;

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

  return (
    <div className="flex flex-col gap-4">
      {allPosts.map((post) => (
        <article
          key={post.id}
          className="rounded-xl border border-white/10 bg-white/5 p-6"
        >
          <h2 className="text-lg font-semibold">{post.title}</h2>
          <p className="mt-2 text-sm text-white/60">{post.body}</p>
        </article>
      ))}

      {/* Sentinel element */}
      <div ref={ref} className="h-8" />

      {isFetchingNextPage && (
        <p className="text-center text-sm text-white/50">Loading more...</p>
      )}

      {!hasNextPage && (
        <p className="text-center text-sm text-white/40">You've reached the end</p>
      )}
    </div>
  );
}

Cursor Pagination on the Backend: What Your API Needs to Return

Your frontend is only as good as the contract your API gives it. For cursor pagination to work, your API needs to return a stable, opaque cursor with each response. "Opaque" means the client treats it as a black box — it doesn't parse it, it just sends it back. This gives you flexibility to change your cursor encoding without breaking clients.

A common pattern in PostgreSQL: encode the last row's id and created_at as a base64 string. Decode it on the next request, then query with WHERE (created_at, id) < ($cursor_created_at, $cursor_id) ORDER BY created_at DESC, id DESC LIMIT 20. This composite cursor handles ties correctly when two rows share the same timestamp.

The response shape your frontend expects looks like this in TypeScript: { items: T[], nextCursor: string | null, hasMore: boolean }. Return null for nextCursor when there are no more pages. Some APIs also include a total count but that count can be expensive to compute — skip it unless you actually need to render "Showing 1–20 of 4,382 results".

Handling Errors, Refetching, and Stale State

What happens when page 3 fails to load? TanStack Query marks that specific page fetch as errored, but the pages already in cache remain intact. The error object and isError flag will be set. You can let the user retry manually by calling fetchNextPage() again — it'll attempt the last failed page.

One thing that trips people up: refetchOnWindowFocus. By default in TanStack Query v5, when a user tabs back to your app, it'll refetch the *entire* infinite query — all pages. For long lists, that's a lot of requests. You'll often want to either disable refetchOnWindowFocus globally or set it to false on specific infinite queries. Think about your use case before accepting the default.

Also consider what "stale" means for your data. A social feed should probably feel fresh — short staleTime. A product catalog might be fine with 5–10 minutes of staleness. You can pair useInfiniteQuery with React toast notifications to show a "New posts available" banner when the query goes stale, giving users control over when to refresh rather than auto-scrolling them back to the top.

Performance Considerations for Large Infinite Lists

Infinite scroll feels snappy at 50 items. At 500 items it can start to drag. The DOM is holding hundreds of nodes in memory, event listeners add up, and layout recalculations get expensive. This is where you need to think about the shape of your list items.

For the Tailwind side: keep card styles simple. rounded-xl border border-white/10 bg-white/5 p-6 with an 8px gap (gap-2 in Tailwind) will compose fine at scale. Avoid heavy box-shadows or backdrop-blur on every card — backdrop-blur triggers GPU compositing and is expensive when applied to many elements simultaneously. If you want that frosted look, check out what glassmorphism is and when to use it before going all in.

For lists that legitimately grow to thousands of items, add a virtual list. @tanstack/react-virtual integrates cleanly with useInfiniteQuery — you measure item sizes, compute visible ranges, and only render those. The rest exist only in the query cache. This is the right long-term solution for feeds, large tables, and anything where the dataset is genuinely unbounded. The React performance guide covers this in more depth if you need numbers.

Prefetching and Bi-directional Infinite Scroll

There are two advanced patterns worth knowing. First: prefetching. You can call queryClient.prefetchInfiniteQuery to load the first page of a list before the user navigates to it. This makes that initial render feel instant. It takes the same options as useInfiniteQuery — same query key, same queryFn, same initialPageParam.

Second: bi-directional infinite scroll, like a chat history that loads newer messages upward and older messages downward. TanStack Query v5 added getPreviousPageParam and fetchPreviousPage for exactly this. You maintain two cursors — one pointing forward in time, one backward. The data.pages array grows in both directions. Scroll position management becomes the hard part; you'll need to save and restore scroll position after prepending pages to avoid the "jump" that happens when new content pushes everything down.

Does everyone need bi-directional scroll? No. Most feeds go one direction. But chat apps, document history, and audit logs often need both. If you find yourself hacking around useInfiniteQuery to make this work, know that the built-in support is there — you just need to reach for getPreviousPageParam.

FAQ

What's the difference between useQuery and useInfiniteQuery in TanStack Query v5?

useQuery manages a single data snapshot — one request, one cache entry. useInfiniteQuery manages a list of pages, accumulating them in data.pages as the user loads more. It also tracks cursor state between pages automatically through getNextPageParam. If your UI has any "load more" or infinite scroll behavior, useInfiniteQuery is the right hook.

How do I reset an infinite query back to the first page?

Call queryClient.resetQueries({ queryKey: ['your-key'] }) — this clears all pages and sets the query back to its initial state. Alternatively, queryClient.removeQueries removes the cache entry entirely, so the next render starts fresh. If you just want to refetch from page one without removing the cache, there's no built-in "reset to page 1" — you'll need to manage that with local state or by changing the query key.

Can I use useInfiniteQuery with GraphQL cursor pagination?

Yes. GraphQL APIs typically return a pageInfo object with hasNextPage and endCursor fields. Your getNextPageParam should return lastPage.data.posts.pageInfo.endCursor when hasNextPage is true, otherwise undefined. The rest of the pattern is identical — flatten data.pages to get all edges, use the intersection observer to trigger fetchNextPage.

Why does refetchOnWindowFocus re-fetch all pages in my infinite query?

That's expected behavior. TanStack Query tracks how many pages were loaded and re-fetches all of them sequentially when the query goes stale. For long lists this can be expensive. Disable it per-query with refetchOnWindowFocus: false in your useInfiniteQuery options, or globally in your QueryClient defaultOptions.

How do I show a loading skeleton only for the initial load, not for subsequent page fetches?

Check the isFetching and isFetchingNextPage flags separately. status === 'pending' or (isFetching && !isFetchingNextPage) means the very first load. isFetchingNextPage means a subsequent page is loading. Render your skeleton only for the first case, and a smaller spinner or loading text for the second.

Is cursor pagination required for useInfiniteQuery, or can I use page numbers?

You can use page numbers — useInfiniteQuery doesn't care what your pageParam is. Return pageParam + 1 from getNextPageParam and pass ?page=${pageParam} to your API. Cursor-based is generally better for performance and consistency, but offset/page-number pagination works fine with this hook.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

TanStack Query Prefetching: SSR, Hydration, DehydrateReact cache() Function: Deduplication for Server ComponentsTanStack Query vs SWR vs Apollo: Data Fetching Library ChoiceTanStack Query vs Zustand: They're Not the Same Thing