Key arguments in Apollo Client
Using the keyArgs API
We recommend reading Core pagination API before learning about considerations specific to keyArgs
configuration.
The Apollo Client cache can store multiple entries for a single schema field. By default, each entry corresponds to a different set of values for the field's arguments.
For example, consider this Query.user
field:
type Query {# Returns whichever User object corresponds to `id`user(id: ID!): User}
If we query for User
s with id
s 1
and 2
, the Apollo Client cache stores entries for both like so:
{'ROOT_QUERY': {'user({"id":"1"})': {'__ref': 'User:1'},'user({"id":"2"})': {'__ref': 'User:2'}}}
As shown above, each entry's storage key includes the corresponding argument values. This means that if any of a field's arguments differ between queries, the storage keys also differ, and those queries result in distinct cache entries.
If a field has no arguments, its storage key is just its name.
This default behavior is for safety: the cache doesn't know whether it can merge the values returned for different argument combinations without invalidating data. In the example above, the cache definitely shouldn't merge the results of querying for User
s with id
s 1
and 2
.
Pagination issues
Certain arguments shouldn't cause the Apollo Client cache to store a separate entry. This is almost always the case for arguments related to paginated lists.
Consider this Query.feed
field:
type Query {feed(offset: Int, limit: Int, category: Category): [FeedItem!]}
The offset
and limit
arguments enable a client to specify which "page" of the feed it wants to fetch. In an app with an infinitely scrolling feed, the client might initially fetch the first ten items, then fetch the next ten:
# First queryquery GetFeedItems {feed(offset: 0, limit: 10, category: "SPORTS")}# Second queryquery GetFeedItems {feed(offset: 10, limit: 10, category: "SPORTS")}
But because their argument values differ, these two lists of ten items are cached separately by default. This means that when the second query completes, the returned items aren't appended to the original list in the feed!
{'ROOT_QUERY': {// First query'feed({"offset":"0","limit":"10","category":"SPORTS"})': [{'__ref': 'FeedItem:1'},// ...additional items...],// Second query'feed({"offset":"10","limit":"10","category":"SPORTS"})': [{'__ref': 'FeedItem:11'},// ...additional items...]}}
In this case, we don't want offset
or limit
to be included in a cache entry's storage key. Instead, we want the cache to merge the results of the two above queries into a single cache entry that includes the items from both lists.
To help handle this case, we can set key arguments for the field.
Setting keyArgs
A key argument is an argument for a GraphQL field that's included in cache storage keys for that field. By default, all GraphQL arguments are key arguments, as shown in our feed example:
{'ROOT_QUERY': {// First query'feed({"offset":"0","limit":"10","category":"SPORTS"})': [{'__ref': 'FeedItem:1'},// ...additional items...],// Second query'feed({"offset":"10","limit":"10","category":"SPORTS"})': [{'__ref': 'FeedItem:11'},// ...additional items...]}}
You can override this default behavior by defining a cache field policy for a particular field:
const cache = new InMemoryCache({typePolicies: {Query: {fields: {feed: {keyArgs: ["category"],},},},},});
This field policy for Query.feed
includes a keyArgs
array, which contains the names of all arguments that the cache should include in its storage keys.
In this case, we don't want the cache to treat offset
or limit
as key arguments, because those arguments don't change which list we're fetching from. However, we do want to treat category
as a key argument, because we want to store our SPORTS
feed separately from other feeds (such as FASHION
or MUSIC
).
After setting keyArgs
as shown, we end up with a single cache entry for our SPORTS
feed (note the absence of offset
and limit
in the storage key):
{'ROOT_QUERY': {'feed({"category":"SPORTS"})': [{'__ref': 'FeedItem:1'},// ...additional items from first query...{'__ref': 'FeedItem:11'},// ...additional items from second query...]}}
Important: After you define keyArgs
for a paginated list field like Query.feed
, you also need to define a merge
function for the field. Otherwise, the list returned by the second query will overwrite the first list instead of merging with it.
Supported values for keyArgs
You can provide the following values for a field's keyArgs
:
false
(indicates that the field has no key arguments)- An array of argument, directive, and variable names
- A function (advanced)
keyArgs
array
A keyArgs
array can include the types of values shown below. The storage key for a cached field uses the values of all arguments, directives, and variables included in the array.
Argument names:
// Here, category and id are two arguments of the field["category", "id"]Nested argument names for input types with subfields:
// Here, details is an input type argument// with subfields name and date["details", ["name", "date"] ]Directive names (indicated with
@
), optionally with one or more of their arguments:// Here, @units is a directive that can be applied// to the field, and it has a type argument["@units", ["type"] ]Variable names (indicated with
$
):// Here, $userId is a variable that's provided to some// operations that include the field["$userId"]
keyArgs
function (advanced)
You can define a completely different format for a field's storage key by providing a custom function to keyArgs
. This function takes the field's arguments and other context as parameters, and it can return any string to use as the storage key (or a dynamically-generated keyArgs
array).
This is for advanced use cases. For details, see FieldPolicy
API reference.
Which arguments belong in keyArgs
?
When deciding which of a field's arguments to include in keyArgs
, it's helpful to start by considering the two extremes: all arguments and no arguments. These initial options help to demonstrate the effects of adding or removing a single argument.
Using all arguments
If all arguments are key arguments (this is the default behavior), every distinct combination of argument values for a field results in a distinct cache entry. In other words, changing any argument value results in a different storage key, so the returned value is stored separately. We see this in our pagination example:
{'ROOT_QUERY': {// First query'feed({"offset":"0","limit":"10","category":"SPORTS"})': [{'__ref': 'FeedItem:1'},// ...additional items...],// Second query'feed({"offset":"10","limit":"10","category":"SPORTS"})': [{'__ref': 'FeedItem:11'},// ...additional items...]}}
With this approach, Apollo Client can't return a cached value for a field unless all of the field's arguments match a previously cached result. This significantly reduces the cache's hit rate, but it also prevents the cache from returning an incorrect value when differences in arguments are relevant (as with our User
example):
{'ROOT_QUERY': {'user({"id":"1"})': {'__ref': 'User:1'},'user({"id":"2"})': {'__ref': 'User:2'}}}
Using no arguments
If no arguments are key arguments (you configure this by setting keyArgs: false
), the field's storage key is just the field's name, without any argument values appended to it. This means that by default, whenever a query returns a value for that field, that value replaces whatever value was already in the cache.
This default behavior is often undesirable (especially for a paginated list), so you can define read
and merge
functions that use argument values to determine how a newly returned value is combined with an existing cached value.
Example
Recall this Query.feed
field from Pagination issues:
type Query {feed(offset: Int, limit: Int, category: Category): [FeedItem!]}
We originally set keyArgs: ["category"]
for this field to keep feed items from different categories separate. We can achieve the same behavior by setting keyArgs: false
and defining the following read
and merge
functions:
const cache = new InMemoryCache({typePolicies: {Query: {fields: {feed: {keyArgs: false,read(existing = {}, { args: { offset, limit, category }}) {return existing[category]?.slice(offset, offset + limit);},merge(existing = {}, incoming, { args: { category, offset = 0 }}) {const merged = existing[category] ? existing[category].slice(0) : [];for (let i = 0; i < incoming.length; ++i) {merged[offset + i] = incoming[i];}existing[category] = merged;return existing;},},},},},});
With the code above, the value of the existing
cached value passed to our read
and merge
functions is a map of category
names to FeedItem
lists. This map enables our single cached field value to store multiple distinct lists. This manual separation is logically equivalent to using keyArgs: ["category"]
, so the extra effort is often unnecessary.
If we know that feeds with different category
values have different data, and we know that our read
function never needs simultaneous access to multiple category feeds, we can safely shift the responsibility for the category
argument to keyArgs
. This enables us to simplify our read
and merge
functions to handle only one feed at a time.
Summary
If the logic for storing and retrieving a field's data is identical for different values of a given argument (like category
above), and the distinct field values are logically independent from one another, then you should probably add that argument to keyArgs
to avoid handling it in your read
and merge
functions.
By contrast, arguments that limit, filter, sort, or otherwise reprocess existing field data usually do not belong in keyArgs
. This is because putting them in keyArgs
makes storage keys more diverse, reducing cache hit rate and limiting your ability to use different arguments to retrieve different views of the same data.
As a general rule, read
and merge
functions can do almost anything with your cached field data, but keyArgs
often provide similar functionality with less code complexity. Whenever possible you should prefer the limited, declarative API of keyArgs
over the unlimited power of functions like merge
and read
.
The @connection
directive
The @connection
directive is a Relay-inspired convention that Apollo Client supports. However, we recommend using keyArgs
instead, because you can achieve the same effect with a single keyArgs
configuration, whereas you need to include the @connection
directive in every query you send to your server.
In other words, whereas Relay encourages the following @connection(...)
directive for Query.feed
queries:
const FEED_QUERY = gql`query Feed($category: FeedCategory!, $offset: Int, $limit: Int) {feed(category: $category, offset: $offset, limit: $limit) @connection(key: "feed",filter: ["category"]) {edges {node { ... }}pageInfo {endCursorhasNextPage}}}`;
in Apollo Client, you can use the following query (the same query without the @connection(...)
directive):
const FEED_QUERY = gql`query Feed($category: FeedCategory!, $offset: Int, $limit: Int) {feed(category: $category, offset: $offset, limit: $limit) {edges {node { ... }}pageInfo {endCursorhasNextPage}}}`;
and instead configure keyArgs
in your Query.feed
field policy:
const cache = new InMemoryCache({typePolicies: {Query: {fields: {feed: {keyArgs: ["category"],},},},},})
If the Query.feed
field does not have an argument like category
that you can use in keyArgs: [...]
, then it might make sense to use the @connection
directive after all:
const FEED_QUERY = gql`query Feed($offset: Int, $limit: Int, $feedKey: String) {feed(offset: $offset, limit: $limit) @connection(key: $feedKey) {edges {node { ... }}pageInfo {endCursorhasNextPage}}}`;
If you execute this query with different values for the $feedKey
variable, those feed results are stored separately in the cache, whereas normally they would all be stored in the same list.
When choosing a keyArgs
configuration for this Query.feed
field, you should include the @connection
directive as if it were an argument (the @
tells InMemoryCache
you mean a directive):
const cache = new InMemoryCache({typePolicies: {Query: {fields: {feed: {keyArgs: ["@connection", ["key"]],},},},},})
With this configuration, your cache uses a feed:{"@connection":{"key":...}}
key instead of just feed
to store separate { edges, pageInfo }
objects within the ROOT_QUERY
object:
expect(cache.extract()).toEqual({ROOT_QUERY: {__typename: "Query",'feed:{"@connection":{"key":"some feed key"}}': { edges, pageInfo },'feed:{"@connection":{"key":"another feed key"}}': { edges, pageInfo },'feed:{"@connection":{"key":"yet another key"}}': { edges, pageInfo },// ...},})
The ["key"]
in keyArgs: ["@connection", ["key"]]
means only the key
argument to the @connection
directive is considered, and any other arguments (like filter
) are ignored. Passing just key
to @connection
is usually adequate, but if you want to pass a filter: ["someArg", "anotherArg"]
argument as well, you should instead include those argument names directly in keyArgs
:
const cache = new InMemoryCache({typePolicies: {Query: {fields: {feed: {keyArgs: ["someArg", "anotherArg", "@connection", ["key"]],},},},},})
If any of these arguments or directives are not provided for the current query, they're omitted from the field key automatically, without error. This means it's generally safe to include more arguments or directives in keyArgs
than you expect to receive in all cases.
As mentioned above, if a keyArgs
array is insufficient to specify your desired field keys, you can alternatively pass a function for keyArgs
, which takes the args
object and a { typename, field, fieldName, variables }
context parameter. This function can return either a string or a dynamically-generated keyArgs
array.
Although keyArgs
(and @connection
) are useful for more than just paginated fields, it's worth noting that relayStylePagination
configures keyArgs: false
by default. You can reconfigure this keyArgs
behavior by passing an alternate value to relayStylePagination
:
const cache = new InMemoryCache({typePolicies: {Query: {fields: {feed: relayStylePagination(["type", "@connection", ["key"]]),},},},})
In the unlikely event that a keyArgs
array is insufficient to capture the identity of a field, remember that you can pass a function for keyArgs
, which allows you to serialize the args
object however you want.