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
:
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:
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 (<Feedentries={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:
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 (<Feedentries={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 exactlyfeed: offsetLimitPagination(["type", "userId"])}
By default, offsetLimitPagination
uses keyArgs: false
(no key arguments).