/
Launch Apollo Studio

Offset-based pagination


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

Offset-based pagination works well for immutable lists, or lists whose element positions are not expected to change, since moving or removing elements could alter the offsets of the elements in the list, sometimes causing elements to be skipped or duplicated if the list is modified on the server between page requests.

Although offset-based pagination has these shortcomings, it is a common pattern found in many applications, in part because it is straightforward to implement on the backend. In SQL, for example, numbered pages can easily be generated by using OFFSET and LIMIT.

The offsetLimitPagination helper

Common pagination strategies can be abstracted away into helper functions, such as the offsetLimitPagination function that @apollo/client/utilities provides:

import { offsetLimitPagination } from "@apollo/client/utilities"

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        feed: offsetLimitPagination(["type"]),
      },
    },
  },
});

The ["type"] argument here specifies the keyArgs to be used for this field policy. By default, offsetLimitPagination uses keyArgs: false.

If you set the field policy above for Query.feed, then you can use fetchMore with useQuery like so:

const FeedData({ type = "PUBLIC" }) {
  const { loading, data, fetchMore } = useQuery(FEED_QUERY, {
    variables: {
      type: type.toUpperCase(),
      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 is changing: the offset. Once the new data is returned from the server, it will be automatically merged with any existing Query.feed data in the cache, which will cause useQuery to rerender with the expanded list of data.

This style of fetchMore usage assumes you want your component to receive the entire available list each time it renders, containing data from all pages received so far. This is a non-paginated read function.

If you are using a Query.feed field policy containing a read function that uses args.offset and args.limit to return a single page of data, the code above will still work, but you may be surprised that your component does not automatically rerender with additional data (beyond the first page) after fetchMore finishes. This happens because the original variables: { offset: 0, limit: 10 } are still in effect, and the first 10 items were not changed by the fetchMore call, so your read function returns the same page as before.

Before you can fix this problem, you first need to think about the behavior that you want. Should your component continue displaying only the first page, or should it now display the page we just received, or should it display the entire list of known data? Regardless of which option you prefer, these alternatives all boil down to the variables you pass to useQuery, which must change if you want your component to render different data.

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

const FeedData({ type = "PUBLIC" }) {
  const [limit, setLimit] = useState(10);
  const { loading, data, fetchMore } = useQuery(FEED_QUERY, {
    variables: {
      type: type.toUpperCase(),
      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.

Edit on GitHub