Core pagination API
Fetching and caching paginated results
Regardless of which pagination strategy your GraphQL server uses for a particular list field, your Apollo Client app needs to do the following to query that field effectively:
Call the
fetchMore
function to fetch the next page of results when neededMerge individual pages of results into a single list in the Apollo Client cache
This article describes these core requirements for paginated fields.
The fetchMore
function
Pagination always involves sending followup queries to your GraphQL server to obtain additional pages of results. In Apollo Client, the recommended way to send these followup queries is with the fetchMore
function. This function is a member of the ObservableQuery
object returned by client.watchQuery
, and it's also provided by the useQuery
hook:
1const FEED_QUERY = gql`
2 query Feed($offset: Int, $limit: Int) {
3 feed(offset: $offset, limit: $limit) {
4 id
5 # ...
6 }
7 }
8`;
9
10const FeedWithData() {
11 const { loading, data, fetchMore } = useQuery(FEED_QUERY, {
12 variables: {
13 offset: 0,
14 limit: 10
15 },
16 });
17 // ...continues below...
18}
You usually call fetchMore
in response to a user action, such as clicking a button or scrolling to the current "bottom" of an infinite-scroll feed.
By default, fetchMore
executes a query with the exact same shape and variables as your original query. You can pass new values for the query's variables
(such as providing a new offset
) like so:
1const FeedWithData() {
2// ...continuing from above...
3
4// If you want your component to rerender with loading:true whenever
5// fetchMore is called, add `notifyOnNetworkStatusChange:true` to the
6// options you pass to useQuery.
7if (loading) return 'Loading...';
8
9return (
10 <Feed
11 entries={data.feed || []}
12 onLoadMore={() => fetchMore({
13 variables: {
14 offset: data.feed.length
15 },
16 })}
17 />
18 );
19}
Here, we set the offset
variable to feed.length
to fetch items after the last item in our cached list. The variables
we provide here are shallow merged with the variables
provided for the original query, which means that variables omitted here (such as limit
) retain their original value (10
) in the followup query.
In addition to variables
, you can optionally provide an entirely different shape of query
to execute. This can be useful when fetchMore
needs to fetch only a single paginated field, but the original query contained unrelated fields.
Additional examples of using
fetchMore
are provided in the detailed documentation for offset-based pagination and cursor-based pagination.
Our fetchMore
function is ready, but we're not finished! The cache doesn't know yet that it should merge our followup query's result with the original query's result. Instead, it will store the two results as two completely separate lists. To resolve this, let's move on to Merging paginated results.
Merging paginated results
The examples in this section use offset-based pagination, but this article applies to all pagination strategies.
As mentioned above, a fetchMore
followup query doesn't automatically merge its result with the original query's cached result. To achieve this behavior, we need to define a field policy for our paginated field.
Why do I need a field policy?
Let's say we have a field in our GraphQL schema that takes an argument:
1type Query {
2 user(id: ID!): User
3}
Now, let's say we execute the following query two times and provide different values for the $id
variable each time:
1query GetUser($id: ID!) {
2 user(id: $id) {
3 id
4 name
5 }
6}
Our two queries return two entirely different User
objects. Helpfully, the Apollo Client cache automatically stores these two objects separately, because it sees that different values were provided for at least one field argument (id
). Otherwise, the cache might overwrite the first User
object with the second User
object, and we want to cache both!
Now, let's say we execute this query two times, with different values for the $offset
variable:
1query Feed($offset: Int, $limit: Int) {
2 feed(offset: $offset, limit: $limit) {
3 id
4 # ...
5 }
6}
In this case, we're querying a paginated list field twice to obtain two different pages of results, and we want those two pages to be merged. But the cache doesn't know that! It sees no difference between this scenario and the User
scenario above, so it stores the results as two completely separate lists.
With field policies, we can modify the cache's behavior for individual fields that require it. For example, we can tell the cache not to store separate results for the feed
field based on the values of offset
and limit
. Let's look at how.
Defining a field policy
A field policy specifies how a particular field in your InMemoryCache
is read and written. You can define a field policy to merge the results of paginated queries into a single list.
Example
Here's the server-side schema for our message feed application that uses offset-based pagination:
1type Query {
2 feed(offset: Int, limit: Int): [FeedItem!]
3}
4
5type FeedItem {
6 id: String!
7 message: String!
8}
In our client, we want to define a field policy for Query.feed
so that all returned pages of the list are merged into a single list in our cache.
We define our field policy within the typePolicies
option we provide the InMemoryCache
constructor:
1const cache = new InMemoryCache({
2 typePolicies: {
3 Query: {
4 fields: {
5 feed: {
6 // Don't cache separate results based on
7 // any of this field's arguments.
8 keyArgs: false,
9
10 // Concatenate the incoming list items with
11 // the existing list items.
12 merge(existing = [], incoming) {
13 return [...existing, ...incoming];
14 },
15 }
16 }
17 }
18 }
19})
This field policy specifies the field's keyArgs
, along with a merge
function. Both of these configurations are necessary for handling pagination:
keyArgs
specifies which of the field's arguments cause the cache to store a separate value for each unique combination of those arguments.In our case, the cache shouldn't store a separate result based on any argument value (
offset
orlimit
). So, we disable this behavior entirely by passingfalse
. An empty array (keyArgs: []
) also works, butkeyArgs: false
is more expressive, and it results in a cleaner field key within the cache (feed
in this case).If a particular argument's value could cause items from an entirely different list to be returned in the field, that argument should be included in
keyArgs
.For more information, see Specifying key arguments and The
keyArgs
API.
A
merge
function tells the Apollo Client cache how to combineincoming
data withexisting
cached data for a particular field. Without this function, incoming field values overwrite existing field values by default.For more information, see The
merge
function.
With this field policy in place, the cache automatically merges the results of all queries that use the following structure, regardless of argument values:
1// Client-side query definition
2const FEED_QUERY = gql`
3 query Feed($offset: Int, $limit: Int) {
4 feed(offset: $offset, limit: $limit) {
5 id
6 message
7 }
8 }
9`;
Improving the merge
function
In the example above, our merge
function is a single line:
1merge(existing = [], incoming) {
2 return [...existing, ...incoming];
3}
This function makes risky assumptions about the order in which the client requests pages, because it ignores the values of offset
and limit
. A more robust merge
function can use options.args
to decide where to put incoming
data relative to existing
data, like so:
1const cache = new InMemoryCache({
2 typePolicies: {
3 Query: {
4 fields: {
5 feed: {
6 keyArgs: false,
7 merge(existing, incoming, { args: { offset = 0 }}) {
8 // Slicing is necessary because the existing data is
9 // immutable, and frozen in development.
10 const merged = existing ? existing.slice(0) : [];
11 for (let i = 0; i < incoming.length; ++i) {
12 merged[offset + i] = incoming[i];
13 }
14 return merged;
15 },
16 },
17 },
18 },
19 },
20});
This logic handles sequential page writes the same way the single-line strategy does, but it can also tolerate repeated, overlapping, or out-of-order writes, without duplicating any list items.
Updating the query with the fetch more result
At times the call to fetchMore
may need to perform additional cache updates for your query. While you can use the cache.readQuery
and cache.writeQuery
functions to to do this work yourself, it can be cumbsersome to use both of these together.
As a shortcut, you can provide the updateQuery
option to fetchMore
to update your query using the result from the fetchMore
call.
updateQuery
is not a replacement for your field policy merge
functions. While you can use updateQuery
without the need to define a merge
function, merge
functions defined for fields in the query will run using the result from updateQuery
.Let's see the example above using updateQuery
to merge results together instead of a field policy merge function:
1fetchMore({
2 variables: { offset: data.feed.length },
3 updateQuery(previousData, { fetchMoreResult, variables: { offset }}) {
4 // Slicing is necessary because the existing data is
5 // immutable, and frozen in development.
6 const updatedFeed = previousData.feed.slice(0);
7 for (let i = 0; i < fetchMoreResult.feed.length; ++i) {
8 updatedFeed[offset + i] = fetchMoreResult.feed[i];
9 }
10 return { ...previousData, feed: updatedFeed };
11 },
12})
keyArgs
value even when you use updateQuery
. This prevents fragmenting the data unnecessarily in the cache. Setting keyArgs
to false
is adequate for most situations to ignore the offset
and limit
arguments and write the paginated data as one big array.read
functions for paginated fields
As shown above, a merge
function helps you combine paginated query results from your GraphQL server into a single list in your client cache. But what if you also want to configure how that locally cached list is read? For that, you can define a read
function.
You define a read
function for a field within its field policy, alongside the merge
function and keyArgs
. If you define a read
function for a field, the cache calls that function whenever you query the field, passing the field's existing cached value (if any) as the first argument. In the query response, the field is populated with the read
function's return value, instead of the existing cached value.
If a field policy includes both a
merge
function and aread
function, the default value ofkeyArgs
becomesfalse
(i.e., no arguments are key arguments). If either function isn't defined, all of the field's arguments are considered key arguments by default. In either case, you can definekeyArgs
yourself to override the default behavior.
A read
function for a paginated field typically uses one of the following approaches:
Re-pagination, in which the cached list is split back into pages, based on field arguments
No pagination, in which the cached list is always returned in full
Although the "right" approach varies from field to field, a non-paginated read
function often works best for infinitely scrolling feeds, because it gives your code full control over which elements to display at a given time, without requiring any additional cache reads.
Paginated read
functions
The read
function for a list field can perform client-side re-pagination for that list. It can even transform a page before returning it, such as by sorting or filtering its elements.
This capability goes beyond returning the same pages that you fetched from your server, because a read
function for offset
/limit
pagination could read from any available offset
, with any desired limit
:
1const cache = new InMemoryCache({
2 typePolicies: {
3 Query: {
4 fields: {
5 feed: {
6 read(existing, { args: { offset, limit }}) {
7 // A read function should always return undefined if existing is
8 // undefined. Returning undefined signals that the field is
9 // missing from the cache, which instructs Apollo Client to
10 // fetch its value from your GraphQL server.
11 return existing && existing.slice(offset, offset + limit);
12 },
13
14 // The keyArgs list and merge function are the same as above.
15 keyArgs: [],
16 merge(existing, incoming, { args: { offset = 0 }}) {
17 const merged = existing ? existing.slice(0) : [];
18 for (let i = 0; i < incoming.length; ++i) {
19 merged[offset + i] = incoming[i];
20 }
21 return merged;
22 },
23 },
24 },
25 },
26 },
27});
Depending on the assumptions you feel comfortable making, you might want to make this code more defensive. For example, you can provide default values for offset
and limit
, in case someone fetches Query.feed
without providing arguments:
1const cache = new InMemoryCache({
2 typePolicies: {
3 Query: {
4 fields: {
5 feed: {
6 read(existing, {
7 args: {
8 // Default to returning the entire cached list,
9 // if offset and limit are not provided.
10 offset = 0,
11 limit = existing?.length,
12 } = {},
13 }) {
14 return existing && existing.slice(offset, offset + limit);
15 },
16 // ... keyArgs, merge ...
17 },
18 },
19 },
20 },
21});
This style of read
function takes responsibility for re-paginating your data based on field arguments, essentially inverting the behavior of your merge
function. This way, your application can query different pages using different arguments.
Non-paginated read
functions
The read
function for a paginated field can choose to ignore arguments like offset
and limit
, and always return the entire list as it exists in the cache. In this case, your application code takes responsibility for slicing the list into pages depending on your needs.
If you adopt this approach, you might not need to define a read
function at all, because the cached list can be returned without any processing. That's why the offsetLimitPagination
helper function is implemented without a read
function.
Regardless of how you configure keyArgs
, your read
(and merge
) functions can always examine any arguments passed to the field using the options.args
object. See The keyArgs
API for a deeper discussion of how to reason about dividing argument-handling responsibility between keyArgs
and your read
or merge
functions.
Using fetchMore
with queries that set a no-cache
fetch policy
fetchMore
with queries that set no-cache
fetch policies. Please see pull request #11974 for more information.The examples shown above use field policies and merge
functions to update the result of a paginated field. But what about queries that use a no-cache
fetch policy? Data is not written to the cache, so field policies have no effect.
To update our query, we provide the updateQuery
option to the fetchMore
function.
Let's use the example above, but instead provide the updateQuery
function to fetchMore
to update the query.
1fetchMore({
2 variables: { offset: data.feed.length },
3 updateQuery(previousData, { fetchMoreResult, variables: { offset }}) {
4 // Slicing is necessary because the existing data is
5 // immutable, and frozen in development.
6 const updatedFeed = previousData.feed.slice(0);
7 for (let i = 0; i < fetchMoreResult.feed.length; ++i) {
8 updatedFeed[offset + i] = fetchMoreResult.feed[i];
9 }
10 return { ...previousData, feed: updatedFeed };
11 },
12})
updateQuery
option is required when using fetchMore
with a no-cache
fetch policy. This is required to correctly determine how the results should be merged since field policy merge
functions are ignored. Calling fetchMore
without an updateQuery
function will throw an error.