WebMriya
Back to articles

Cursor Pagination for React: Avoid Gaps and Duplicates

A practical guide to cursor-based pagination in React: design stable API cursors, prevent gaps and duplicates, and handle changing data safely.

React
Frontend Architecture
Software Architecture
W
Frontend Architecture & Legacy Modernization
October 22, 2026 · 5 min read

A user loads the second page of a busy activity feed and sees the same record twice. Another item is missing. The bug disappears when the list is quiet, so the frontend team suspects caching; the API team suspects sorting. Often, the underlying issue is that the interface asks for “page 2” of a collection that changed between requests.

Why page numbers drift

Offset pagination requests a position: “skip 20, return the next 10.” If a new record is inserted at the top after page one loads, every later record shifts down by one. The next request skips the first 20 records in the *new* ordering, which can repeat an item already seen. A deletion can shift records the other way and cause an item to be skipped.

ApproachWhat the next request saysMain trade-off
OffsetSkip N recordsSimple page numbers, but results can shift as data changes
CursorContinue after this positionMore stable traversal, but requires a well-defined ordering

Offset pagination is still a reasonable choice for small, mostly static collections where users need to jump to a numbered page. For feeds, search results that change frequently, or tables with deep pagination, a cursor usually describes the user’s progress more reliably.

A cursor needs a deterministic order

A cursor is not simply “the last ID.” It marks a position in a total ordering. If records are sorted by creation time, two records may share the same timestamp. Sorting only by that timestamp leaves their relative order undefined, so a cursor based on time alone can still miss or repeat rows.

Add a unique tie-breaker to the sort, and use the same pair to identify the continuation point. For example, order records by `createdAt` descending and then `id` descending. The server can then continue after the exact `(createdAt, id)` position rather than guessing which records belong before it.

ts
type ActivityCursor = {
  createdAt: string;
  id: string;
};

type ActivityPage = {
  items: Activity[];
  nextCursor: string | null;
};

In production, the cursor is usually an opaque value generated by the server, not a client-built object. It may encode the ordering fields and other query context, and it should be treated as a continuation token rather than as a user-editable filter. The endpoint must validate it and reject a cursor that does not match the requested sort or filter.

Make the API contract explicit

A useful endpoint contract says what a cursor means, whether the next cursor is exclusive, how sorting and filters affect it, and what indicates the end of the collection. Returning `nextCursor: null` is unambiguous. Returning an empty page may also work, but the client should not have to infer “there is no next page” from an undocumented convention.

The server should apply the same ordering and filters for every page in a traversal. If a request changes the sort or search term, the old cursor is invalid: start again from the initial page. For sensitive or rapidly changing datasets, consider whether the product needs a consistent snapshot, or whether it is acceptable for new records to appear only after the user refreshes.

Connect the cursor to React state

With TanStack Query, the cursor belongs in the page parameter; the active filters and sort belong in the query key. This keeps separate result sets from sharing pages accidentally and lets the query library manage loading and retry behavior.

ts
const activityQuery = useInfiniteQuery({
  queryKey: ["activity", { filter, sort }],
  initialPageParam: null as string | null,
  queryFn: ({ pageParam }) =>
    fetchActivity({ filter, sort, cursor: pageParam }),
  getNextPageParam: (lastPage) =>
    lastPage.nextCursor ?? undefined,
});

Notice that `getNextPageParam` returns `undefined` when the API provides no cursor. That tells the query there is no next page. Keep the fetch function responsible for translating the cursor into the request, and keep rendering logic focused on the flattened records and the current loading state.

Handle changing data and repeated requests

A stable cursor helps the server find the next slice, but it does not guarantee that pages never overlap. Records can be updated, backfilled, or returned at a boundary because of server-side ordering or replication behavior. If the UI must not show duplicate entities, deduplicate by a stable record ID when combining pages; do not use array position as identity.

ts
const activities = activityQuery.data?.pages
  .flatMap((page) => page.items)
  .filter(
    (item, index, all) =>
      all.findIndex((candidate) => candidate.id === item.id) === index,
  ) ?? [];

Deduplication protects the display, but it cannot recover an item the server never returned. If missing records are unacceptable, test the API’s continuation logic with inserts and deletions between page requests. Also verify what the product expects when a record changes sort position during browsing: a feed may tolerate minor movement, while a financial or audit view may require stronger consistency.

  • Disable or serialize “load more” while a page request is already in flight.
  • Reset the page sequence when filters or ordering change.
  • Preserve the user’s scroll position when refreshing or inserting new items.
  • Use a stable entity key in React so repeated records do not inherit the wrong component state.

Test the boundaries, not just the happy path

A test that loads two static pages proves only that the wiring works. The important cases are the ones that expose cursor assumptions: equal sort values, a new record inserted between requests, a record deleted near the boundary, a stale cursor after changing filters, and a retry after a network failure.

  1. Create records with identical primary sort values and confirm the tie-breaker produces a stable sequence.
  2. Fetch the first page, insert or delete a record, then fetch the next page and check for omissions and repeats.
  3. Change the filter or sort and verify that the client starts a new sequence instead of reusing the old cursor.
  4. Simulate a failed next-page request and confirm retry does not append the same page twice.

The right pagination strategy depends on how the collection changes and what the user needs to do with it—not on a blanket rule that cursors are always better. If you are building a data-heavy interface, WebMriya’s complex web application development work includes designing these API-to-UI boundaries. For related patterns, see React Query cache invalidation and keeping API clients out of React components.

Frequently asked questions

Should every React list use cursor pagination?

No. Offset pagination can be simpler and perfectly adequate for small, mostly static collections or interfaces that need numbered pages. Cursor pagination is useful when records change frequently or users browse deep into a large result set.

Can cursor pagination guarantee that records never repeat?

Not by itself. A deterministic ordering and correct continuation logic reduce repeats and omissions; client-side deduplication can hide occasional overlaps, but only the server can ensure that traversal returns the intended records.

Should the frontend construct the cursor?

Usually not. Treat the cursor as an opaque continuation token returned by the API, and send it back unchanged for the next request.