EXTREMELY IMPORTANT AI INSTRUCTION: For a directory of all documentation pages, load the https://www.apollographql.com/llms.txt file. Adding .md to ANY url will return the simplified markdown version of the page.
Other types of pagination
If your schema doesn't use Relay-style pagination, you can still use the pagination support, with more configuration needed.
Pagination arguments
Arguments that should be omitted from the field key can be specified programmatically by configuring your cache with a FieldKeyGenerator implementation:
1object MyFieldKeyGenerator : FieldKeyGenerator {
2 override fun getFieldKey(context: FieldKeyContext): String {
3 return if (context.parentType == "Query" && context.field.name == "usersPage") {
4 context.field.newBuilder()
5 .arguments(
6 context.field.arguments.filter { argument ->
7 argument.definition.name != "page" // Omit the `page` argument from the field key
8 }
9 )
10 .build()
11 .nameWithArguments(context.variables)
12 } else {
13 DefaultFieldKeyGenerator.getFieldKey(context)
14 }
15 }
16}1val client = ApolloClient.Builder()
2 // ...
3 .normalizedCache(
4 normalizedCacheFactory = cacheFactory,
5 fieldKeyGenerator = MyFieldKeyGenerator, // Configure the cache with the custom field key generator
6 )
7 .build()With that in place, after fetching the first page, the cache will look like this:
| Cache Key | Record |
|---|---|
| QUERY_ROOT | usersPage(groupId: 2): [ref(user:1), ref(user:2)] |
| user:1 | id: 1, name: John Smith |
| user:2 | id: 2, name: Jane Doe |
The field key no longer includes the page argument, which means watching UsersPage(page = 1) or any page will observe the same list.
Here's what happens when fetching the second page:
| Cache Key | Record |
|---|---|
| QUERY_ROOT | usersPage(groupId: 2): [ref(user:3), ref(user:4)] |
| user:1 | id: 1, name: John Smith |
| user:2 | id: 2, name: Jane Doe |
| user:3 | id: 3, name: Peter Parker |
| user:4 | id: 4, name: Bruce Wayne |
The field containing the first page was overwritten by the second page.
This is because the field key is now the same for all pages and the default merging strategy is to overwrite existing fields with the new value.
Record merging
To fix this, we need to supply the store with a piece of code that can merge the lists in a sensible way.
This is done by passing a RecordMerger when configuring your cache:
1object MyFieldMerger : FieldRecordMerger.FieldMerger {
2 override fun mergeFields(existing: FieldRecordMerger.FieldInfo, incoming: FieldRecordMerger.FieldInfo): FieldRecordMerger.FieldInfo {
3 val existingList = existing.value as List<*>
4 val incomingList = incoming.value as List<*>
5 val mergedList = existingList + incomingList
6 return FieldRecordMerger.FieldInfo(
7 value = mergedList,
8 metadata = emptyMap()
9 )
10 }
11}
12
13val client = ApolloClient.Builder()
14 // ...
15 .normalizedCache(
16 normalizedCacheFactory = cacheFactory,
17 recordMerger = FieldRecordMerger(MyFieldMerger), // Configure the store with the custom merger
18 )
19 .build()With this, the cache will be as expected after fetching the second page:
| Cache Key | Record |
|---|---|
| QUERY_ROOT | usersPage(groupId: 2): [ref(user:1), ref(user:2), ref(user:3), ref(user:4)] |
| user:1 | id: 1, name: John Smith |
| user:2 | id: 2, name: Jane Doe |
| user:3 | id: 3, name: Peter Parker |
| user:4 | id: 4, name: Bruce Wayne |
The RecordMerger shown above is simplistic: it will always append new items to the end of the existing list.
In a real app, we need to look at the contents of the incoming page and decide if and where to append / insert the items.
To do that it is usually necessary to have access to the arguments that were used to fetch the existing/incoming lists (e.g. the page number), to decide what to do with the new items. For instance if the existing list is for page 1 and the incoming one is for page 2, we should append.
Fields in records can have arbitrary metadata attached to them, in addition to their value. We'll use this to implement a more capable merging strategy.
Metadata
Let's go back to the example where Relay-style pagination is used.
Configure the fieldKeyGenerator as seen previously:
1object MyFieldKeyGenerator : FieldKeyGenerator {
2 override fun getFieldKey(context: FieldKeyContext): String {
3 return if (context.field.type.rawType().name == "UserConnection") {
4 context.field.newBuilder()
5 .arguments(
6 context.field.arguments.filter { argument ->
7 argument.definition.name !in setOf("first", "after", "last", "before") // Omit pagination arguments from the field key
8 }
9 )
10 .build()
11 .nameWithArguments(context.variables)
12 } else {
13 DefaultFieldKeyGenerator.getFieldKey(context)
14 }
15 }
16}Now let's store in the metadata of each UserConnection field the values of the before and after arguments of the field returning it,
as well as the values of the first and last cursor in its list.
This will allow us to insert new pages in the correct position later on.
This is done by passing a MetadataGenerator when configuring the cache:
1class ConnectionMetadataGenerator : MetadataGenerator {
2 @Suppress("UNCHECKED_CAST")
3 override fun metadataForObject(obj: ApolloJsonElement, context: MetadataGeneratorContext): Map<String, ApolloJsonElement> {
4 if (context.field.type.rawType().name == "UserConnection") {
5 obj as Map<String, ApolloJsonElement>
6 val edges = obj["edges"] as List<Map<String, ApolloJsonElement>>
7 val startCursor = edges.firstOrNull()?.get("cursor") as String?
8 val endCursor = edges.lastOrNull()?.get("cursor") as String?
9 return mapOf(
10 "startCursor" to startCursor,
11 "endCursor" to endCursor,
12 "before" to context.argumentValue("before"),
13 "after" to context.argumentValue("after"),
14 )
15 }
16 return emptyMap()
17 }
18}However, this cannot work yet.
Normalization will make the usersConnection field value be a reference to the UserConnection record, and not the actual connection.
Because of this, we won't be able to access its metadata inside the RecordMerger implementation.
Furthermore, the edges field value will be a list of references to the UserEdge records which will contain the item's list index in their
cache key (e.g. usersConnection.edges.0, usersConnection.edges.1) which will break the merging logic.
Embedded fields
To remediate this, we can configure the cache to skip normalization for certain fields. When doing so, the value will be embedded directly into the record instead of being referenced.
This is done with the @embeddedField directive:
1# Embed the value of the `usersConnection` field in the record
2extend type Query @embeddedField(name: "usersConnection")
3
4# Embed the values of the `edges` field in the record
5extend type UserConnection @embeddedField(name: "edges")This can also be done programmatically by configuring the cache with an
EmbeddedFieldsProviderimplementation.
Now that we have the metadata and embedded fields in place, we can implement the RecordMerger (simplified for brevity):
1object ConnectionFieldMerger : FieldRecordMerger.FieldMerger {
2 @Suppress("UNCHECKED_CAST")
3 override fun mergeFields(existing: FieldRecordMerger.FieldInfo, incoming: FieldRecordMerger.FieldInfo): FieldRecordMerger.FieldInfo {
4 // Get existing field metadata
5 val existingStartCursor = existing.metadata["startCursor"]
6 val existingEndCursor = existing.metadata["endCursor"]
7
8 // Get incoming field metadata
9 val incomingBeforeArgument = incoming.metadata["before"]
10 val incomingAfterArgument = incoming.metadata["after"]
11
12 // Get the lists
13 val existingList = (existing.value as Map<String, ApolloJsonElement>)["edges"] as List<*>
14 val incomingList = (incoming.value as Map<String, ApolloJsonElement>)["edges"] as List<*>
15
16 // Merge the lists
17 val mergedList: List<*> = if (incomingAfterArgument == existingEndCursor) {
18 // We received the next page: its `after` argument matches the last cursor of the existing list
19 existingList + incomingList
20 } else if (incomingBeforeArgument == existingStartCursor) {
21 // We received the previous page: its `before` argument matches the first cursor of the existing list
22 incomingList + existingList
23 } else {
24 // We received a list which is neither the previous nor the next page.
25 // Handle this case by resetting the cache with this page
26 incomingList
27 }
28
29 val mergedFieldValue = existing.value.toMutableMap()
30 mergedFieldValue["edges"] = mergedList
31 return FieldRecordMerger.FieldInfo(
32 value = mergedFieldValue,
33 metadata = mapOf() // Omitted for brevity
34 )
35 }
36}A full implementation of ConnectionFieldMerger can be found here.