Type something to search...
Pagination Strategies for React Applications

Pagination Strategies for React Applications

Rendering ten thousand rows at once is slow to download, slow to render, and useless to the person looking at it. Every list that can grow needs some form of pagination. The hard part isn't splitting data into pages. It's picking the strategy that fits your data and your users, and then handling the details: stale pages, shifting results, the Back button, and loading states that don't make the screen jump.

There are three common strategies. Offset pagination with numbered pages, cursor pagination that follows a pointer from one page to the next, and load more or infinite lists that keep appending. Each has trade-offs on both the server and the client.

This guide explains how each one works, shows working React implementations with TanStack Query, and covers URL syncing, prefetching, and the mistakes that make paginated UIs feel broken.

Client-Side vs Server-Side Pagination

Before choosing a strategy, decide where the slicing happens.

Client-side pagination loads the full dataset once and shows a slice of it. It's simple and instant to navigate, and it's fine for small, bounded data such as a settings table with 200 rows.

import { useState } from "react";

type User = { id: number; name: string };

export function ClientPagedList({ users, pageSize = 20 }: { users: User[]; pageSize?: number }) {
  const [page, setPage] = useState(1);
  const totalPages = Math.max(1, Math.ceil(users.length / pageSize));
  const visible = users.slice((page - 1) * pageSize, page * pageSize);

  return (
    <>
      <ul>
        {visible.map((u) => (
          <li key={u.id}>{u.name}</li>
        ))}
      </ul>
      <button onClick={() => setPage((p) => p - 1)} disabled={page === 1}>
        Previous
      </button>
      <span>
        Page {page} of {totalPages}
      </span>
      <button onClick={() => setPage((p) => p + 1)} disabled={page === totalPages}>
        Next
      </button>
    </>
  );
}

Server-side pagination asks the API for one page at a time. It's the only option once data is large or unbounded, such as orders, logs, or a social feed. The rest of this post focuses on server-side strategies.

Strategy 1: Offset Pagination

Offset pagination is the classic "page 3 of 12" pattern. The client sends a page number (or an offset) and a page size, and the server responds with that slice plus a total count.

curl "https://api.example.com/products?page=3&limit=20"

On the server, this usually maps to SQL like LIMIT 20 OFFSET 40.

Strengths:

  • Users can jump directly to any page.
  • The total count lets you show "Page 3 of 12" and "Showing 41 to 60 of 238."
  • It's trivial to implement and to put in the URL.

Weaknesses:

  • Large offsets get slow. The database still has to walk past every skipped row, so OFFSET 100000 is far slower than OFFSET 0.
  • Results shift. If a new item is inserted at the top while a user is on page 2, every item moves down by one. Page 3 then repeats the last item of page 2. Deletions cause items to be skipped.
  • Counting is expensive on very large tables.

Offset pagination is the right choice for admin tables, search results, and catalogs where users want to jump around and the data doesn't change every second.

Offset Pagination With TanStack Query

TanStack Query makes paginated fetching straightforward. The page number goes into the query key, so each page is cached separately.

// api.ts
export type Product = { id: number; title: string; price: number };
export type ProductPage = { products: Product[]; total: number; skip: number; limit: number };

export async function fetchProducts(page: number, limit: number): Promise<ProductPage> {
  const params = new URLSearchParams({
    limit: String(limit),
    skip: String((page - 1) * limit),
  });
  const res = await fetch(`https://dummyjson.com/products?${params}`);
  if (!res.ok) throw new Error(`Failed to load products: ${res.status}`);
  return res.json();
}
import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { fetchProducts } from "./api";

const PAGE_SIZE = 20;

export function ProductTable() {
  const [page, setPage] = useState(1);

  const { data, isPending, isError, isFetching, isPlaceholderData } = useQuery({
    queryKey: ["products", { page, limit: PAGE_SIZE }],
    queryFn: () => fetchProducts(page, PAGE_SIZE),
    placeholderData: keepPreviousData,
  });

  if (isPending) return <p>Loading...</p>;
  if (isError) return <p role="alert">Could not load products.</p>;

  const totalPages = Math.ceil(data.total / PAGE_SIZE);

  return (
    <div aria-busy={isFetching}>
      <table style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
        <tbody>
          {data.products.map((p) => (
            <tr key={p.id}>
              <td>{p.title}</td>
              <td>${p.price}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <nav aria-label="Pagination">
        <button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page === 1}>
          Previous
        </button>
        <span>
          Page {page} of {totalPages}
        </span>
        <button
          onClick={() => setPage((p) => p + 1)}
          disabled={isPlaceholderData || page >= totalPages}
        >
          Next
        </button>
      </nav>
    </div>
  );
}

placeholderData: keepPreviousData is the key detail. Without it, switching to page 4 changes the query key, the new query has no data, and the table disappears into a loading spinner before the new page arrives. With it, page 3 stays on screen (dimmed) until page 4 is ready. Disabling Next while isPlaceholderData is true prevents users from skipping ahead past a page that hasn't loaded yet.

Prefetching the Next Page

Users on page 3 are likely to click Next. You can fetch page 4 in the background so it appears instantly.

import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { fetchProducts } from "./api";

export function usePrefetchNextPage(page: number, totalPages: number, limit: number) {
  const queryClient = useQueryClient();

  useEffect(() => {
    if (page >= totalPages) return;
    queryClient.prefetchQuery({
      queryKey: ["products", { page: page + 1, limit }],
      queryFn: () => fetchProducts(page + 1, limit),
    });
  }, [page, totalPages, limit, queryClient]);
}

The query key must match exactly what the table uses, or the prefetched data lands in a different cache entry and is never read.

Building Page Number Controls

"Previous" and "Next" are fine for short lists, but large result sets need numbered links with ellipses, such as 1 … 4 5 6 … 20. Computing that range is a pure function that's easy to test separately from the UI.

// getPageRange.ts
export type PageItem = number | "ellipsis";

export function getPageRange(current: number, total: number, siblings = 1): PageItem[] {
  const totalShown = siblings * 2 + 5; // first, last, current, two ellipses
  if (total <= totalShown) {
    return Array.from({ length: total }, (_, i) => i + 1);
  }

  const left = Math.max(current - siblings, 2);
  const right = Math.min(current + siblings, total - 1);
  const items: PageItem[] = [1];

  if (left > 2) items.push("ellipsis");
  for (let p = left; p <= right; p++) items.push(p);
  if (right < total - 1) items.push("ellipsis");

  items.push(total);
  return items;
}
import { getPageRange } from "./getPageRange";

type Props = { page: number; totalPages: number; onChange: (page: number) => void };

export function Pagination({ page, totalPages, onChange }: Props) {
  const items = getPageRange(page, totalPages);

  return (
    <nav aria-label="Pagination">
      <ul className="pagination">
        {items.map((item, i) =>
          item === "ellipsis" ? (
            <li key={`e${i}`} aria-hidden="true">
              …
            </li>
          ) : (
            <li key={item}>
              <button
                onClick={() => onChange(item)}
                aria-current={item === page ? "page" : undefined}
              >
                {item}
              </button>
            </li>
          ),
        )}
      </ul>
    </nav>
  );
}

aria-current="page" tells assistive technology which page is active, and wrapping the controls in a labeled nav makes them easy to find.

Keeping the Page in the URL

If the page number only lives in useState, refreshing the page resets to page 1, the Back button leaves the list entirely, and nobody can share a link to page 5. Put it in the query string instead.

With React Router v7, useSearchParams replaces the local state almost one-to-one:

import { useSearchParams } from "react-router";

export function usePageParam() {
  const [searchParams, setSearchParams] = useSearchParams();
  const raw = Number(searchParams.get("page"));
  const page = Number.isInteger(raw) && raw > 0 ? raw : 1;

  function setPage(next: number) {
    setSearchParams((prev) => {
      const params = new URLSearchParams(prev);
      if (next <= 1) params.delete("page");
      else params.set("page", String(next));
      return params;
    });
  }

  return [page, setPage] as const;
}

Validate the value. Users and crawlers will visit ?page=abc and ?page=-4, and your component shouldn't crash or request nonsense. Unlike search input, page changes are deliberate actions, so pushing a history entry for each one is correct: Back should return to the previous page.

When filters change, reset the page to 1. Staying on page 7 after narrowing the results to two pages shows an empty list.

Strategy 2: Cursor Pagination

Cursor pagination replaces "skip N rows" with "give me the rows after this one." The server returns a page of items plus an opaque cursor, usually an encoded ID or timestamp of the last item. The client sends that cursor back to get the next page.

curl "https://api.example.com/posts?limit=20"
# { "items": [...], "nextCursor": "eyJpZCI6MTIzfQ" }

curl "https://api.example.com/posts?limit=20&cursor=eyJpZCI6MTIzfQ"
# { "items": [...], "nextCursor": null }

On the server, the query becomes something like WHERE id < 123 ORDER BY id DESC LIMIT 20, which uses an index and stays fast no matter how deep you go.

Strengths:

  • Consistent performance at any depth.
  • Stable results. New inserts at the top don't shift what's on the next page, so there are no duplicates or skipped items.
  • It works naturally for feeds, chat histories, and activity logs.

Weaknesses:

  • You can't jump to page 50. You can only go forward (and backward, if the API supports a previous cursor).
  • Total counts are usually unavailable or approximate.
  • Sorting by arbitrary columns needs a compound cursor (for example, sort value plus ID as a tiebreaker).

GitHub, Stripe, and Slack all use cursor pagination in their APIs for exactly these reasons.

Cursor Pagination With useInfiniteQuery

TanStack Query's useInfiniteQuery is built for cursor-based APIs. You describe how to fetch a page given a cursor, and how to find the next cursor from the last page.

// fetchPosts.ts
export type Post = { id: string; title: string; createdAt: string };
export type PostsPage = { items: Post[]; nextCursor: string | null };

export async function fetchPosts(cursor: string | null, signal: AbortSignal): Promise<PostsPage> {
  const params = new URLSearchParams({ limit: "20" });
  if (cursor) params.set("cursor", cursor);
  const res = await fetch(`/api/posts?${params}`, { signal });
  if (!res.ok) throw new Error(`Failed to load posts: ${res.status}`);
  return res.json();
}
import { useInfiniteQuery } from "@tanstack/react-query";
import { fetchPosts } from "./fetchPosts";

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

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

  return (
    <>
      <ul>
        {data.pages.map((page) =>
          page.items.map((post) => <li key={post.id}>{post.title}</li>),
        )}
      </ul>
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? "Loading more..." : "Load more"}
        </button>
      )}
    </>
  );
}

When getNextPageParam returns null or undefined, hasNextPage becomes false and the button disappears. In TanStack Query v5, initialPageParam is required, which is why it's set explicitly here.

Strategy 3: Load More and Infinite Scroll

"Load more" isn't really a separate server strategy. It's a UI pattern that sits on top of either offset or cursor pagination, appending pages instead of replacing them. Cursor pagination is the better backend for it, because appending offset pages to a list that changes underneath leads to duplicates.

Infinite scroll is the same idea, triggered automatically when a sentinel element near the bottom of the list scrolls into view. The infinite scroll guide walks through the IntersectionObserver setup in full. The short version is to observe a sentinel element and call fetchNextPage() when it becomes visible and hasNextPage is true.

A few cautions specific to appending UIs:

  • Lists grow without bound. After a few hundred items, the DOM gets heavy. Virtualizing long lists with TanStack Virtual keeps only the visible rows rendered.
  • Memory grows too. useInfiniteQuery accepts a maxPages option that keeps only the most recent N pages in the cache. If you set it, you also need getPreviousPageParam so pages can be refetched when scrolling back up.
  • Footers become unreachable with infinite scroll. If your page has important footer links, use a Load More button instead.
  • Position is lost on navigation. Returning from a detail page should restore the scroll position, which is much easier if the cached pages are still there when the list remounts.

Choosing the Right Strategy

Use this as a starting point:

SituationStrategy
Small, bounded dataset (a few hundred rows)Client-side pagination
Admin tables, search results, catalogsOffset with page numbers
Feeds, timelines, chat history, logsCursor with load more or infinite scroll
Huge tables where users jump to a pageOffset, but cap the maximum page or use keyset jumps
Data that changes constantlyCursor

If you're designing the API yourself, cursor pagination is the safer default. It scales, it's consistent, and you can still build Previous and Next buttons on top of it. Add offset pagination when users genuinely need to jump to arbitrary pages.

Common Mistakes With Pagination

  • Not resetting the page when filters change. Users land on an empty page 7 of a 2-page result. Reset to page 1 whenever the filter or sort changes.
  • Forgetting stable sort order. Sorting by a non-unique column like createdAt without an ID tiebreaker can return the same row on two pages. Always add a unique column to the ORDER BY.
  • Clearing the list between pages. Without keepPreviousData, the table collapses to a spinner on every click, and the page layout jumps.
  • Keeping the page only in component state. Refresh and Back both lose the user's place. Store it in the URL.
  • Trusting the page parameter. Validate that it's a positive integer and clamp it to the available range.
  • Appending offset pages to a live list. New items shift offsets and cause duplicates. Use cursors for appending UIs, or de-duplicate by ID.
  • Using array index as the key. When pages shift or prepend, index keys cause React to reuse the wrong DOM nodes. Use stable IDs.

Frequently Asked Questions (FAQ) About Pagination in React

Offset pagination asks for a numbered slice, such as rows 41 to 60, and lets users jump to any page. Cursor pagination asks for rows after a specific item using a pointer returned by the previous page. Cursors are faster on large datasets and don't duplicate or skip items when data changes, but they don't support jumping to an arbitrary page.

Use placeholderData: keepPreviousData in TanStack Query. The previous page stays visible while the next page loads, and isPlaceholderData tells you when the data on screen is stale so you can dim it or disable controls.

Yes, for most server-paginated lists. Storing the page in the query string means refreshing keeps the user's place, the Back button returns to the previous page, and links to a specific page can be shared. Validate the value since anyone can type any page number into the URL.

It depends on the content. Infinite scroll suits feeds that people browse casually. Numbered pagination suits tasks where users need to find something specific, compare items, or return to a known position. Infinite scroll also makes footers hard to reach, so a Load More button is often a good middle ground.

Many cursor APIs don't return one because counting large tables is expensive. If you need it, have the server return an estimated total, or run a separate count query that you cache for a while. Often you can drop the exact count and show Load More or Next until there are no more pages.

Between 20 and 50 items is typical for lists and tables. Smaller pages load faster but require more clicks. Larger pages reduce requests but increase render time and payload size. Let users choose the page size on data-heavy admin screens.

Conclusion

Pagination strategy is a decision about both your data and your users. Offset pagination gives you numbered pages, totals, and the ability to jump around, which is ideal for tables and search results. Cursor pagination gives you stable, fast results at any depth, which is ideal for feeds and logs. Load More and infinite scroll are UI layers that work best on top of cursors.

On the React side, TanStack Query handles the hard parts: useQuery with keepPreviousData for numbered pages, useInfiniteQuery for cursors, and prefetchQuery for instant navigation. Put the page in the URL, reset it when filters change, use stable sort orders and keys, and your paginated lists will feel fast and predictable.

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