Launch Apollo Studio

Offset-based pagination


We recommend reading Core pagination API before learning about considerations specific to offset-based pagination.

With offset-based pagination, a list field accepts an offset argument that indicates where in the list the server should start when returning items for a particular query. The field usually also accepts a limit argument that indicates the maximum number of items to return:

type Query {
  feed(offset: Int, limit: Int): [FeedItem!]}

type FeedItem {
  id: ID!
  message: String!
}

This pagination strategy works well for immutable lists, or for lists where each item's index never changes. In other cases, you should avoid it in favor of cursor-based pagination, because moving or removing items can shift offsets. This causes items to be skipped or duplicated if changes occur between paginated queries.

Although it has limitations, offset-based pagination is a common pattern in many applications, in part because it's relatively straightforward to implement.

The offsetLimitPagination helper

Apollo Client provides an offsetLimitPagination helper function that you can use to generate a field policy for every relevant list field.

This example uses offsetLimitPagination to generate a field policy for Query.feed:

index.js
import { InMemoryCache } from "@apollo/client";
import { offsetLimitPagination } from "@apollo/client/utilities";
const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feed: offsetLimitPagination()      },
    },
  },
});

This defines a merge function for the field that handles merging paginated results in the cache for you (see the source).

Using with fetchMore

If you use offsetLimitPagination to set your feed policy as shown above, then you can use fetchMore with useQuery like so:

FeedData.jsx
const FeedData() {
  const { loading, data, fetchMore } = useQuery(FEED_QUERY, {
    variables: {
      offset: 0,
      limit: 10
    },
  });

  // If you want your component to rerender with loading:true whenever
  // fetchMore is called, add notifyOnNetworkStatusChange:true to the
  // options you pass to useQuery above.
  if (loading) return <Loading/>;

  return (
    <Feed
      entries={data.feed || []}
      onLoadMore={() => fetchMore({        variables: {          offset: data.feed.length        },      })}    />
  );
}

By default, fetchMore uses the original query and variables, so we only need to pass the variable that's changing: offset. When new data is returned from the server, it's automatically merged with any existing Query.feed data in the cache. This causes useQuery to rerender with the expanded list of data.

In this example, the Feed component receives the entire cached list (data.feed) every time it renders, which includes data from all pages received so far. This is a non-paginated read function.

Using with a paginated read function

In the example above, the GraphQL server returns individual pages of results, but each query then returns all cached results received so far. To limit each query's result to only the items you requested, you can include a paginated read function in your field policy.

Because the offsetLimitPagination helper is currently defining your field policy, you combine your read function with the helper's result, like so:

index.js
import { InMemoryCache } from "@apollo/client";
import { offsetLimitPagination } from "@apollo/client/utilities";

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feed: {          ...offsetLimitPagination(),          read(existing, { args }) {            // Implement here          }        }      },
    },
  },
});

For example implementations, see Paginated read functions.

If you use a paginated read function, you probably need to update your offset and limit variables as required by your use case after you call fetchMore. Otherwise, you'll continue rendering only the first page of results.

For example, to display all the data received so far, you could modify the previous example as follows:

const FeedData = () => {
  const [limit, setLimit] = useState(10);
  const { loading, data, fetchMore } = useQuery(FEED_QUERY, {
    variables: {
      offset: 0,
      limit,
    },
  });

  if (loading) return <Loading/>;

  return (
    <Feed
      entries={data.feed || []}
      onLoadMore={() => {
        const currentLength = data.feed.length;
        fetchMore({
          variables: {
            offset: currentLength,
            limit: 10,
          },
        }).then(fetchMoreResult => {
          // Update variables.limit for the original query to include
          // the newly added feed items.
          setLimit(currentLength + fetchMoreResult.data.feed.length);
        });
      }}
    />
  );
}

This code uses a React useState Hook to store the current limit value, which it updates by calling setLimit in a callback attached to the Promise returned by fetchMore.

You could store offset in a React useState Hook as well, if you need the offset to change. Exactly when and how these variables change is up to your component, and may not always be the result of calling fetchMore, so it makes sense to use React component state to store these variable values.

If you are not using React and useQuery, the ObservableQuery object returned by client.watchQuery has a method called setVariables that you can call to update the original variables.

Because fetchMore requires some extra work to update the original variables if you're using a read function that is sensitive to those variables (the second kind of read function), it's fair to say fetchMore encourages the first kind of read function, which simply returns all available data.

However, now that you understand your options, there's nothing wrong with moving read-time pagination logic out of your application code and into your field read functions. Both kinds of read functions have their uses, and both can be made to work with fetchMore.

Setting keyArgs with offsetLimitPagination

If a paginated field accepts arguments besides offset and limit, you might need to specify the key arguments that indicate whether two result sets belong to the same list or different lists.

To set keyArgs for the field policy generated by offsetLimitPagination, provide an array of argument names to the function as a parameter:

fields {
  // Results belong to the same list only if both the type
  // and userId arguments match exactly
  feed: offsetLimitPagination(["type", "userId"])
}

By default, offsetLimitPagination uses keyArgs: false (no key arguments).

Edit on GitHub