By default, the results of all graphql requests made with ApolloClient
are cached in the browser's memory, so that on subsequent requests of the same query there won't be another round trip to the server, because the requested data can be served from the cache instead. Of course, this approach vastly reduces server load, but it also introduces a whole world of cache management issues.
- What if you do something that changes the data in your application? You'll have to keep the cache up to date somehow.
- What if another user changes something that affects the data you see? Since there won't be another server request, you'll have to find another way to update your cache.
ApolloClient
offers a bunch of cache management tools and some documentation to solve these issues, but unfortunately they ignore or glance over some of the more complicated use cases, many of which will have to be tackled at some point in a moderately complex real-world application.
ApolloClient
includes a caching interface called ApolloCache
and one proprietary implementation called InMemoryCache
. It's the most prominent cache implementation for ApolloClient
and the one we're going to focus on, because it's the only one that apollo-augmented-hooks
works with.
- What does the cache look like?
- How do I update the cache after a mutation?
- How do I add something to the cache?
- How do I handle parameterized queries?
- How do I add something to the cache using apollo-augmented-hooks?
- How do I update a specific cache item rather than the root query?
- How do I delete something from the cache?
- How do I handle n:m relationships in the cache?
- How do I append a new field to a cache object?
In order to correctly handle cache updates, it's important to understand the cache's structure. A little tip before we dive into it: For debugging purposes, you can use window.__APOLLO_CLIENT__.cache.extract()
in your browser's console to view the current cache contents.
The InMemoryCache
's structure is a simple normalised object. When the cache is empty, it is an empty object:
{}
Now imagine we're requesting the following query from the server:
query {
todos {
id
title
}
}
The server responds with two todos:
{
data: {
todos: [{
__typename: 'Todo',
id: '36bad921-8fcf-4f33-9f29-0d3cd70205c8',
title: 'Buy groceries'
}, {
__typename: 'Todo',
id: 'a2096556-9a4e-4994-9de8-86c9e85ed6a1',
title: 'Do the dishes'
}]
}
}
Without any further action on your part, ApolloClient
will update the cache so that it looks like this:
{
ROOT_QUERY: {
__typename: 'Query',
todos: [{
__ref: 'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8'
}, {
__ref: 'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1'
}]
},
'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8': {
__typename: 'Todo',
id: '36bad921-8fcf-4f33-9f29-0d3cd70205c8',
title: 'Buy groceries'
},
'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1': {
__typename: 'Todo',
id: 'a2096556-9a4e-4994-9de8-86c9e85ed6a1',
title: 'Do the dishes'
}
}
Let's unpack this. The cache object now has an element with the key ROOT_QUERY
. This element is an object containing the results of all the queries we have made. Since we requested the field todos
, the ROOT_QUERY
object contains an element with the key todos
. You'll notice that the todos
array doesn't have the same structure as the server response. Instead, each array item consists of an object containing simply an element with the __ref
key and a value built from the todo's __typename
and its id
. The data itself has been normalised and added to the cache object itself. Next to the ROOT_QUERY
element, you will find the actual todos, each with the same key as the __ref
s we saw before.
This happens with arbitrarily deep queries. Imagine we're requesting the following query:
query {
todos {
id
title
user {
id
name
}
}
}
The server response might look like this:
{
data: {
todos: [{
__typename: 'Todo',
id: '36bad921-8fcf-4f33-9f29-0d3cd70205c8',
title: 'Buy groceries',
user: {
__typename: 'User',
id: '2adb1120-d911-4196-ab1b-d5043cc7a00a',
name: 'mindnektar'
}
}, {
__typename: 'Todo',
id: 'a2096556-9a4e-4994-9de8-86c9e85ed6a1',
title: 'Do the dishes',
user: {
__typename: 'User',
id: '2adb1120-d911-4196-ab1b-d5043cc7a00a',
name: 'mindnektar'
}
}]
}
}
Because the cache is normalised, it will now look like this:
{
ROOT_QUERY: {
__typename: 'Query',
todos: [{
__ref: 'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8'
}, {
__ref: 'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1'
}]
},
'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8': {
__typename: 'Todo',
id: '36bad921-8fcf-4f33-9f29-0d3cd70205c8',
title: 'Buy groceries',
user: {
__ref: 'User:2adb1120-d911-4196-ab1b-d5043cc7a00a'
}
},
'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1': {
__typename: 'Todo',
id: 'a2096556-9a4e-4994-9de8-86c9e85ed6a1',
title: 'Do the dishes',
user: {
__ref: 'User:2adb1120-d911-4196-ab1b-d5043cc7a00a'
}
},
'User:2adb1120-d911-4196-ab1b-d5043cc7a00a': {
__typename: 'User',
id: '2adb1120-d911-4196-ab1b-d5043cc7a00a',
name: 'mindnektar'
}
}
The ROOT_QUERY
looks exactly like before, and each todo contains a reference to the user. And even though - since both todos are allocated to the same user - the server responded with duplicated data (the user's name attached to each todo), the cache's normalisation causes that data to be present only once.
This behaviour has one great advantage: Whenever a cache item is updated (e.g. because the user's name has changed), the entire cache object (with possibly hundreds or thousands of items) doesn't have to be traversed in search for instances of that user, but only a single item needs to be taken care of.
What happens if we request a field that doesn't have an id, though? Then the cache item won't be normalised. An example query:
query {
todos {
id
title
user {
name
}
}
}
The server response:
{
data: {
todos: [{
__typename: 'Todo',
id: '36bad921-8fcf-4f33-9f29-0d3cd70205c8',
title: 'Buy groceries',
user: {
__typename: 'User',
name: 'mindnektar'
}
}, {
__typename: 'Todo',
id: 'a2096556-9a4e-4994-9de8-86c9e85ed6a1',
title: 'Do the dishes',
user: {
__typename: 'User',
name: 'mindnektar'
}
}]
}
}
And the cache:
{
ROOT_QUERY: {
__typename: 'Query',
todos: [{
__ref: 'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8'
}, {
__ref: 'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1'
}]
},
'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8': {
__typename: 'Todo',
id: '36bad921-8fcf-4f33-9f29-0d3cd70205c8',
title: 'Buy groceries',
user: {
name: 'mindnektar'
}
},
'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1': {
__typename: 'Todo',
id: 'a2096556-9a4e-4994-9de8-86c9e85ed6a1',
title: 'Do the dishes',
user: {
name: 'mindnektar'
}
}
}
This should be avoided at all costs. If any data of an unnormalised cache item needs to be updated, you will have to manually update each occurrence in the entire cache, which is unmaintainable. You must also never request the same thing sometimes with an id and sometimes without, because ApolloClient
will throw an error when trying to update the cache after such a query.
Let me reiterate: Always include an id for each requested field.
Of course, sometimes an id might not be available because that particular type is identified differently, either by another name or by a combination of fields:
query {
users {
name
email
}
}
Maybe users have no id and are instead identified by their combination of name and email. Luckily, you can very easily specify these exceptions in your InMemoryCache
configuration. This is what the cache might look like:
{
ROOT_QUERY: {
__typename: 'Query',
users: [{
__ref: 'User:{"name":"mindnektar","email":"[email protected]"}'
}]
},
'User:{"name":"mindnektar","email":"[email protected]"}': {
__typename: 'User',
name: 'mindnektar',
email: '[email protected]'
}
}
The vast majority of cache updates that you want to do are one of these three things:
- You want to update an item that is already in the cache
- You want to add a new item to the cache
- You want to delete an item from the cache
Conveniently, ApolloClient
automatically takes care of point 1 for us. Imagine the cache looks like this:
{
ROOT_QUERY: {
__typename: 'Query',
users: [{
__ref: 'User:2adb1120-d911-4196-ab1b-d5043cc7a00a'
}]
},
'User:2adb1120-d911-4196-ab1b-d5043cc7a00a': {
__typename: 'User',
id: '2adb1120-d911-4196-ab1b-d5043cc7a00a',
name: 'mindnektar',
email: '[email protected]'
}
}
Now the user calls a mutation that changes his email address:
mutation {
updateUserEmail(email: "[email protected]") {
id
email
}
}
The server returns:
{
data: {
updateUserEmail: {
__typename: 'User',
id: '2adb1120-d911-4196-ab1b-d5043cc7a00a',
email: '[email protected]'
}
}
}
Because we already have an item with the key User:2adb1120-d911-4196-ab1b-d5043cc7a00a
in the cache, ApolloClient
knows to automatically update it with the data returned by the mutation:
{
ROOT_QUERY: {
__typename: 'Query',
users: [{
__ref: 'User:2adb1120-d911-4196-ab1b-d5043cc7a00a'
}]
},
'User:2adb1120-d911-4196-ab1b-d5043cc7a00a': {
__typename: 'User',
id: '2adb1120-d911-4196-ab1b-d5043cc7a00a',
name: 'mindnektar',
email: '[email protected]'
}
}
If we hadn't requested the id along with the email, ApolloClient
would have been unable to find the matching item in the cache and no update would have occurred. I will reiterate again: Always include the id field (or any other applicable key fields) everywhere.
When it comes to adding or deleting cache items however, ApolloClient
can't possibly know what we expect to happen with the mutation response, so we have to do that manually. There are a couple of ways to achieve that. Before Apollo 3, cache updates were quite cumbersome and caused a lot of overhead. You had to use methods like cache.readQuery
, cache.writeQuery
, cache.readFragement
and cache.writeFragment
, which required you to use queries much like the ones you use to request data from the server. We will ignore those methods in favour of cache.modify
, which is easier to use and much more flexible. Unfortunately, even cache.modify
has its slew of problems, many of which apollo-augmented-hooks
attempts to solve. We will take a look at how cache updates work the regular way using cache.modify
, and then contrast that with the apollo-augmented-hooks
solution.
Keeping with the example above, our cache initially looks like this:
{
ROOT_QUERY: {
__typename: 'Query',
users: [{
__ref: 'User:2adb1120-d911-4196-ab1b-d5043cc7a00a'
}]
},
'User:2adb1120-d911-4196-ab1b-d5043cc7a00a': {
__typename: 'User',
id: '2adb1120-d911-4196-ab1b-d5043cc7a00a',
name: 'mindnektar'
}
}
Now we create a new user with this mutation:
mutation {
createUser(name: "foobar") {
id
name
}
}
The server returns:
{
data: {
createUser: {
__typename: 'User',
id: '141738bf-3622-4beb-b0c5-0622e1e7311f',
name: 'foobar'
}
}
}
The cache does not include a user with this id, so no automatic updates are performed. The item will, however, be appended to the cache automatically - it just won't be referenced anywhere:
{
ROOT_QUERY: {
__typename: 'Query',
users: [{
__ref: 'User:2adb1120-d911-4196-ab1b-d5043cc7a00a'
}]
},
'User:2adb1120-d911-4196-ab1b-d5043cc7a00a': {
__typename: 'User',
id: '2adb1120-d911-4196-ab1b-d5043cc7a00a',
name: 'mindnektar'
},
'User:141738bf-3622-4beb-b0c5-0622e1e7311f': {
__typename: 'User',
id: '141738bf-3622-4beb-b0c5-0622e1e7311f',
name: 'foobar'
}
}
Let's take a look at the mutation hook:
import { gql, useMutation } from '@apollo/client';
// Hard-coded name parameter for simplicity
const mutation = gql`
mutation {
createUser(name: "foobar") {
id
name
}
}
`;
export default () => {
const [mutate] = useMutation(mutation);
return () => (
mutate({
update: (cache, mutationResult) => {
// TODO: Update cache here
}
})
);
}
When calling the mutate function returned by this hook, the server request will be launched. Once it completes, the update function passed to the mutate function will be called, allowing you to manipulate the cache using the mutation result. We do that by calling cache.modify
. cache.modify
accepts a single options parameter, and for the vast majority of use cases you'll only need two of these options: id
and fields
.
Remembering the shape of the normalised cache object, each key references either an item in the cache or the ROOT_QUERY
. cache.modify
's id
option lets you choose which cache item to modify. For example, to modify a specific user, you might pass User:2adb1120-d911-4196-ab1b-d5043cc7a00a
, or to modify the root query, you'll either pass ROOT_QUERY
or simply omit the id
option.
fields
is a bit more complex. It is an object that allows you to specify how each field of the chosen cache item should be modified. In our example above, we are creating a new user, so it stands to reason that we would like to add it to our list of users in the cache. This can be done the following way:
update: (cache, mutationResult) => {
cache.modify({
fields: {
users: (previous) => {
const newUserRef = cache.writeFragment({
data: mutationResult.data.createUser,
fragment: gql`
fragment NewUser on User {
id
name
}
`
});
return [...previous, newUserRef];
}
}
});
}
As you can see, even in this very simple example, the cache modification is rather verbose already. Here's what's happening: We're calling cache.modify
with its options parameter, omitting id
(because we want to modify the root query) and passing fields
. fields
is an object containing all the fields that we wish to modify on the cache item - in this case we are only interested in users
, but we could modify any number of fields on the root query at once. users
is a modifier function. It has two parameters: the current cache contents and an object containing several helpers for interacting with the cache. In our example, we only need the current cache contents, so we omit the second parameter. The modifier function expects us to return the new cache contents, so with [...previous, newUserRef]
we do exactly that: we return an array containing all the previous users and the user that was just created.
Now the question is, why do we have to do all that cache.writeFragment
stuff rather than just return [...previous, mutationResult.data.createUser]
? It's because the cache is normalised. If we didn't generate a reference using cache.writeFragment
, the cache would end up looking like this:
{
ROOT_QUERY: {
__typename: 'Query',
users: [{
__ref: 'User:2adb1120-d911-4196-ab1b-d5043cc7a00a'
}, {
__typename: 'User',
id: '9541f397-69ce-4abf-9275-9e80b5058853',
name: 'foobar'
}]
},
'User:2adb1120-d911-4196-ab1b-d5043cc7a00a': {
__typename: 'User',
id: '2adb1120-d911-4196-ab1b-d5043cc7a00a',
name: 'mindnektar'
}
}
We would have a mix of normalised and non-normalised items in our users array, which would actually still work for the time being, but it would cause all kinds of trouble down the line, when trying to update the cache in the future. This happens to be one of the biggest gotchas in Apollo's cache management, because if you do it like this, you will likely not notice at first because no errors or warnings are thrown. You will only notice the aftereffects at a later point, when it will be difficult to figure out where the problem originally came from. So with emphasis: Always return refs in modifier functions, never the object itself.
The method explained above is the one recommended by the official documentation, but there happens to be a simpler way (even without using apollo-augmented-hooks
) that for some reason is not part of the cache.modify
documentation but buried deep in an unrelated section. This is how it works:
update: (cache, mutationResult) => {
cache.modify({
fields: {
users: (previous, { toReference }) => (
[...previous, toReference(mutationResult.data.createUser)]
)
}
});
}
The modifier function's second parameter includes an undocumented but quite official helper function called toReference
. The key difference between it and cache.writeFragment
is that the former only returns a ref to an already present cache object, while the latter writes an object to the cache and then returns its ref. Luckily, ApolloClient
calls cache.writeFragment
internally after every mutation response or subscription message, so there is no need for us to do it ourselves. We can always rely on the useMutation
and useSubscription
results to end up in the cache automatically, so toReference
is all we're ever going to need. It's much easier to use and produces cleaner and more maintainable code.
The only time you're ever going to have to use cache.writeFragment
yourself is when you're calling cache.modify
in isolation, meaning you're outside the context of a mutation response or a subscription message and have to ensure an object's presence in the cache yourself before referencing it elsewhere within the cache.
Before we take a look at how cache modification works with apollo-augmented-hooks
, let's cover a slightly more complex example that you have to deal with all the time in real-world applications, but for which there exists no official solution at the time I'm writing this.
Imagine our todos query was parameterised:
query todos($filter: TodoFilter!) {
todos(filter: $filter) {
id
title
}
}
The TodoFilter
might allow the user to specify a time interval so the server only responds with todos that were created in that interval:
import { gql, useQuery } from '@apollo/client';
const query = gql`
query todos($filter: TodoFilter!) {
todos(filter: $filter) {
id
title
}
}
`;
export default () => (
useQuery(query, {
variables: {
filter: {
from: '2021-04-01',
to: '2021-04-30'
}
}
})
);
Now what would the cache look like if we fired off such a query, possibly multiple times with different filters?
{
ROOT_QUERY: {
__typename: 'Query',
'todos({"filter":{"from":"2021-04-01","to":"2021-04-30"}})': [{
__ref: 'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8'
}, {
__ref: 'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1'
}],
'todos({"filter":{"from":"2021-05-01","to":"2021-05-31"}})': [],
'todos({"filter":{"from":"2021-04-01","to":"2021-05-31"}})': [{
__ref: 'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8'
}, {
__ref: 'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1'
}]
},
'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8': {
__typename: 'Todo',
id: '36bad921-8fcf-4f33-9f29-0d3cd70205c8',
title: 'Buy groceries'
},
'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1': {
__typename: 'Todo',
id: 'a2096556-9a4e-4994-9de8-86c9e85ed6a1',
title: 'Do the dishes'
}
}
Depending on how many possible permutations there are for the filter, the root query might fill up quickly with lots of different items for the same query. Each different set of filters produces an additional cache item. So what happens if we create a new todo and update the cache using cache.modify
like we did before?
update: (cache, mutationResult) => {
cache.modify({
fields: {
todos: (previous, { toReference }) => (
[...previous, toReference(mutationResult.data.createTodo)]
)
}
});
}
If we specifiy the todos
field, the modifier function will be called once for every single cache item that was created with the todos query. The result is straightforward: Our new todo will be added to each cache item, no matter if it matches the parameters or not. This means that we may now have incorrect data in the cache. What we need to do instead is check if the new todo should be added to the cache item in each call of the modifier function.
Unfortunately, Apollo doesn't provide a convenient way to tell what the parameters for the current modifier function call are. The only thing we get is the storeFieldName
helper. It contains the full name of the todos
field we're currently handling, e.g. todos({"filter":{"from":"2021-04-01","to":"2021-04-30"}})
. So while it is possible to access the filter parameters, it is on you to extract the data from the string so you can do your checks, possibly like this:
update: (cache, mutationResult) => {
cache.modify({
fields: {
todos: (previous, { toReference, storeFieldName }) => {
const jsonVariables = storeFieldName.substring(
storeFieldName.indexOf('{'),
storeFieldName.lastIndexOf('}') + 1
);
const variables = JSON.parse(jsonVariables);
const now = new Date();
const from = new Date(variables.filter.from);
const to = new Date(variables.filter.to);
if (now >= from && now <= to) {
return [...previous, toReference(mutationResult.data.createTodo)];
}
return previous;
}
}
});
}
Though this is already annoying enough, there are some cases in which the parameterised cache keys are formatted like this instead: todos:{"filter":{"from":"2021-04-01","to":"2021-04-30"}}
, so you'll have to handle those cases as well. All of these things are not mentioned in the official documentation, so you'll have to stumble across them yourself or one of the many years-spanning github issues where people are endlessly discussing this. This is one of the main problems that apollo-augmented-hooks
seeks to solve.
Pretty similarly to the official way, but there are some key differences:
import { useMutation } from 'apollo-augmented-hooks';
// Hard-coded name parameter for simplicity
const mutation = `
mutation {
createUser(name: "foobar") {
id
name
}
}
`;
export default () => {
const [mutate] = useMutation(mutation);
return () => (
mutate({
modifiers: [
// TODO: Update cache here
]
})
);
}
- We import
useMutation
fromapollo-augmented-hooks
, not from@apollo/client
. - We don't need
gql
to transform the graphql string into an abstract syntax tree - this is done internally byuseMutation
(though you can still manually wrap the string ingql
, if you wish). - Rather than passing an
update
function to the mutate call, you pass amodifiers
array.
Let's take a look at how we can modify the cache in our example using the modifiers
array.
modifiers: [{
fields: {
todos: ({ includeIf, variables }) => {
const now = new Date();
const from = new Date(variables.filter.from);
const to = new Date(variables.filter.to);
return includeIf(now >= from && now <= to);
}
}
}]
modifiers
is an array of objects, each of which will result in one call of cache.modify
. The main difference is the signature of the modifier function. Originally, the first parameter was the previous cache content and the second parameter an object containing a bunch of helpers. The new signature includes only that helper object, because often you don't even need the previous cache content. If you do, you can access it on the helper object, with the previous
key.
In our example, we don't need it because we make use of the includeIf
helper instead. includeIf
is a convenience function used for updating arrays in the cache - it returns the previous cache content, either with the server response included or removed, depending on whether the passed parameter is truthy or falsy. Additionally, we can use the variables
helper, which contains the query variables for each cache item that we're updating, saving us the trouble of manually parsing storeFieldName
.
Aside from previous
, variables
, includeIf
and all the other official helpers, the helper object also includes item
(which is a less verbose way to access the server response than mutationResult.data.createTodo
) and itemRef
, which is synonymous with toReference(item)
.
For this use case, let's start with this cache:
{
ROOT_QUERY: {
__typename: 'Query',
users: [{
__ref: 'User:2adb1120-d911-4196-ab1b-d5043cc7a00a'
}, {
__ref: 'User:141738bf-3622-4beb-b0c5-0622e1e7311f'
}]
},
'User:2adb1120-d911-4196-ab1b-d5043cc7a00a': {
__typename: 'User',
id: '2adb1120-d911-4196-ab1b-d5043cc7a00a',
name: 'mindnektar',
todos: []
},
'User:141738bf-3622-4beb-b0c5-0622e1e7311f': {
__typename: 'User',
id: '141738bf-3622-4beb-b0c5-0622e1e7311f',
name: 'foobar',
todos: []
}
}
Our application might consist of a number of user profiles, each of which contains a list of todos, and a user can create todos to display on their profile. The user foobar
might want to do just that:
mutation {
createTodo(title: "Do the dishes") {
id
title
}
}
The server returns:
{
data: {
createTodo: {
__typename: 'Todo',
id: 'a2096556-9a4e-4994-9de8-86c9e85ed6a1',
title: 'Do the dishes'
}
}
}
In this example, there is no todos
root query we would append this new todo to - instead, we want to add it to the todos
list belonging to the user who created it. The official way works like this:
update: (cache, mutationResult) => {
cache.modify({
id: 'User:141738bf-3622-4beb-b0c5-0622e1e7311f',
fields: {
todos: (previous, { toReference }) => (
[...previous, toReference(mutationResult.data.createTodo)]
)
}
});
}
The id
parameter specifies the key of the cache item that we wish to update - other than that, it works just like a modification to the root query. Of course, we want this code to work for any user, not just foobar
, so we need to generate the id
parameter dynamically.
import { gql, useMutation } from '@apollo/client';
// Hard-coded title parameter for simplicity
const mutation = gql`
mutation {
createTodo(title: "Do the dishes") {
id
title
}
}
`;
export default (user) => {
const [mutate] = useMutation(mutation);
return () => (
mutate({
update: (cache, mutationResult) => {
cache.modify({
id: `${user.__typename}:${user.id}`,
fields: {
todos: (previous, { toReference }) => (
[...previous, toReference(mutationResult.data.createTodo)]
)
}
});
}
})
);
}
You could pass the user object into the mutation hook from the component rendering it, assuming you've previously requested it with a graphql query. The user object contains __typename
and id
properties, so we can concatenate those to build our cache key. I'm sure you'll agree that this is not particularly elegant, and it will even fail to work when id
is not the key field - luckily, there is a built-in convenience method:
update: (cache, mutationResult) => {
cache.modify({
id: cache.identify(user),
fields: {
todos: (previous, { toReference }) => (
[...previous, toReference(mutationResult.data.createTodo)]
)
}
});
}
cache.identify
transforms an object containing __typename
and the respective key fields to generate the cache key for us.
With apollo-augmented-hooks
, you simply provide a cacheObject
prop rather than id
. cache.identify
is done internally.
modifiers: [{
cacheObject: user,
fields: {
todos: ({ includeIf }) => (
includeIf(true)
)
}
}]
There are a couple of ways, depending on what needs to be deleted. One of the most common use cases is removing an item from a list. Here's our example cache:
{
ROOT_QUERY: {
__typename: 'Query',
todos: [{
__ref: 'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8'
}, {
__ref: 'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1'
}]
},
'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8': {
__typename: 'Todo',
id: '36bad921-8fcf-4f33-9f29-0d3cd70205c8',
title: 'Buy groceries'
},
'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1': {
__typename: 'Todo',
id: 'a2096556-9a4e-4994-9de8-86c9e85ed6a1',
title: 'Do the dishes'
}
}
The mutation to delete a todo might look like this:
mutation {
deleteTodo(id: "36bad921-8fcf-4f33-9f29-0d3cd70205c8") {
id
}
}
And this is how we would remove the todo after the mutation the Apollo way:
update: (cache, mutationResult) => {
cache.modify({
fields: {
todos: (previous, { readField }) => (
previous.filter((ref) => (
readField('id', ref) !== mutationResult.data.deleteTodo.id
))
)
}
});
}
Just like when adding a todo, we modify the todos
field by returning a new array - but this time, we need to filter the todo returned by the server from the list rather than append it. The problem is that each item in the todos
array is not the actual todo in the cache, but the reference object (e.g. { __ref: 'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8' }
), so we can't just use that for the comparison with the mutation result's id. Instead, we'll have to use the readField
helper to get the id
field from the actual cache item.
With apollo-augmented-hooks
, we can simply use the includeIf
helper again, which does the same thing internally:
modifiers: [{
fields: {
todos: ({ includeIf }) => (
includeIf(false)
)
}
}]
In either case, the cache will look like this:
{
ROOT_QUERY: {
__typename: 'Query',
todos: [{
__ref: 'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1'
}]
},
'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8': {
__typename: 'Todo',
id: '36bad921-8fcf-4f33-9f29-0d3cd70205c8',
title: 'Buy groceries'
},
'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1': {
__typename: 'Todo',
id: 'a2096556-9a4e-4994-9de8-86c9e85ed6a1',
title: 'Do the dishes'
}
}
The reference is no longer in the todos
array, but the cache item is still there. This is not an inherently terrible thing, but it is always better to keep the cache as clean as possible, and it will also benefit performance. One way is to call cache.gc()
, which will garbage-collect all cache items that are not referenced anywhere else in the cache. Another is cache eviction:
update: (cache, mutationResult) => {
cache.evict({
id: cache.identify(mutationResult.data.deleteTodo)
});
cache.gc();
}
Calling cache.evict
will remove the specified item from the cache. It is advisable to do a cache.gc
afterwards anyway, because evicting the cache item might cause other cache items to no longer be referenced. See this very helpful section in the official documentation for more info on what happens under the hood when using cache eviction.
The modifiers
option in apollo-augmented-hooks
also includes a convenient way to do the above:
modifiers: [{
cacheObject: (item) => item,
evict: true,
}]
Since we don't have access to the mutation result in the modifiers array, cacheObject
allows us to pass a function rather than the required cache object itself. That function's only parameter is synonymous with mutationResult.data.deleteTodo
, so since that is exactly what we want to evict, we can simply return it here.
Evicting cache items is the recommended way to remove them from the cache, because with it you won't have to modify every single reference to the removed cache item manually. Apollo automatically ignores dangling references to evicted cache objects - though this only works for references in arrays, so if your cache item is not referenced within an array, you'll have to remove the reference yourself.
Cache updates can get rather tricky and difficult to maintain when your graphql API models a classical n:m relationship between types. Imagine our API contains these types:
type Todo {
id: ID!
title: String!
categories: [Category!]!
}
type Category {
id: ID!
name: String!
todos: [Todo!]!
}
Our todos can be allocated to a number of categories, and each category can contain a number of todos. When browsing our application, the cache might end up like this:
{
ROOT_QUERY: {
__typename: 'Query',
todos: [{
__ref: 'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8'
}, {
__ref: 'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1'
}],
categories: [{
__ref: 'Category:bd21e369-5aa6-4cdd-ad3f-d82c9a829fa3'
}, {
__ref: 'Category:3bd99efa-8a85-4dd3-91e5-a8e9e671ad6d'
}]
},
'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8': {
__typename: 'Todo',
id: '36bad921-8fcf-4f33-9f29-0d3cd70205c8',
title: 'Buy groceries',
categories: [{
__ref: 'Category:3bd99efa-8a85-4dd3-91e5-a8e9e671ad6d'
}]
},
'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1': {
__typename: 'Todo',
id: 'a2096556-9a4e-4994-9de8-86c9e85ed6a1',
title: 'Do the dishes',
categories: [{
__ref: 'Category:bd21e369-5aa6-4cdd-ad3f-d82c9a829fa3'
}]
},
'Category:bd21e369-5aa6-4cdd-ad3f-d82c9a829fa3': {
__typename: 'Category',
id: 'bd21e369-5aa6-4cdd-ad3f-d82c9a829fa3',
name: 'Urgent',
todos: [{
__ref: 'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1'
}]
},
'Category:3bd99efa-8a85-4dd3-91e5-a8e9e671ad6d': {
__typename: 'Category',
id: '3bd99efa-8a85-4dd3-91e5-a8e9e671ad6d',
name: 'Involves leaving the house',
todos: [{
__ref: 'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8'
}]
}
}
As you can see, we have the todos "Buy groceries" and "Do the dishes", with the former being allocated to the category "Involves leaving the house", while the latter is "Urgent". Now what if the user decides that the dishes can lie around a bit longer, but he desperately needs to grab a sixpack for tonight's game? He would then mark "Buy groceries" as "Urgent" and remove that category from "Do the dishes", possibly using a mutation like this:
import { useMutation } from 'apollo-augmented-hooks';
const mutation = `
mutation updateCategory($input: UpdateCategoryInput!) {
updateCategory(input: $input) {
id
todos {
id
}
}
}
`;
export default (user) => {
const [mutate] = useMutation(mutation);
// Hard-coded input for simplicity
const input = {
id: 'bd21e369-5aa6-4cdd-ad3f-d82c9a829fa3',
todos: [{
id: '36bad921-8fcf-4f33-9f29-0d3cd70205c8'
}]
};
return () => (
mutate({
input,
modifiers: [
// TODO: Update cache here
]
})
);
};
After the mutation is done, we're of course leveraging Apollo's automatic cache updates again. updateCategory
returns the id
and __typename
of a Category
object, and because it is already in the cache, Apollo knows to update it with the server response:
{
ROOT_QUERY: {
__typename: 'Query',
todos: [{
__ref: 'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8'
}, {
__ref: 'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1'
}],
categories: [{
__ref: 'Category:bd21e369-5aa6-4cdd-ad3f-d82c9a829fa3'
}, {
__ref: 'Category:3bd99efa-8a85-4dd3-91e5-a8e9e671ad6d'
}]
},
'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8': {
__typename: 'Todo',
id: '36bad921-8fcf-4f33-9f29-0d3cd70205c8',
title: 'Buy groceries',
categories: [{
__ref: 'Category:3bd99efa-8a85-4dd3-91e5-a8e9e671ad6d'
}]
},
'Todo:a2096556-9a4e-4994-9de8-86c9e85ed6a1': {
__typename: 'Todo',
id: 'a2096556-9a4e-4994-9de8-86c9e85ed6a1',
title: 'Do the dishes',
categories: [{
__ref: 'Category:bd21e369-5aa6-4cdd-ad3f-d82c9a829fa3'
}]
},
'Category:bd21e369-5aa6-4cdd-ad3f-d82c9a829fa3': {
__typename: 'Category',
id: 'bd21e369-5aa6-4cdd-ad3f-d82c9a829fa3',
name: 'Urgent',
todos: [{
__ref: 'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8'
}]
},
'Category:3bd99efa-8a85-4dd3-91e5-a8e9e671ad6d': {
__typename: 'Category',
id: '3bd99efa-8a85-4dd3-91e5-a8e9e671ad6d',
name: 'Involves leaving the house',
todos: [{
__ref: 'Todo:36bad921-8fcf-4f33-9f29-0d3cd70205c8'
}]
}
}
According to the cache, the "Urgent" category now contains the "Buy groceries" todo, which is exactly what we want - but the "Buy groceries" todo still only has the "Involves leaving the house" category, and "Do the dishes" is still "Urgent". We now have an inconsistent cache state that returns different things depending on how you query them. A category's page will list the correct todos, but a todo's page will list incorrect categories. This can lead to major errors down the line.
In order to fix this, we will need to modify each Todo
cache object that used to contain the "Urgent" category and now doesn't, as well as each Todo
cache object that didn't contain the "Urgent" category and now does. This can become a difficult problem, because the only thing we have at our disposal is the list of todos that we sent to the server - and that's only the todos that now contain the "Urgent" category. What about the ones that don't contain it anymore?
apollo-augmented-hooks
offers a way to solve this issue with a single modifier:
modifiers: [{
typename: 'Todo',
fields: {
categories: ({ cacheObject, includeIf }) => (
includeIf(input.todos.some(({ id }) => id === cacheObject.id))
)
}
}]
What happens here? The most important bit is the typename
option that we're using in place of the cacheObject
option we usually need. typename
causes the modifier function to be called once for every single object in the cache that matches the passed typename. And in the modifier function itself, we once again make use of the includeIf
helper to determine whether or not the Category
object from the server response should be added to the categories
field. The cacheObject
helper contains the current iteration's Todo
object, so to decide if the Category
object should be included, we can simply check if our input.todos
array contains the current cache object's id.
With this relatively simple modifier, we have covered every possible permutation of categories and todos that updateCategory
could ever produce. And if our application happens to allow setting a todo's categories as well (using an updateTodo
mutation), updating the cache is as easy as using the same modifier as above, but having todos and categories switch places.
As the name suggests, cache.modify
only allows you to change the values of already present fields. But what if you want to add a new field to a cache object? With cache.modify
, it can't be done, and most of the time, there won't be any reason to even attempt it, but there are certain plausible scenarios.
Imagine the following: Access to your application is limited to authenticated users. Before the first graphql query is sent to the server, a user will have to authenticate using a login form. Submitting the form will fire off a mutation which - if successful - will return an auth token. The now authenticated user will be redirected to the application's dashboard, where we can now send the query along with the auth token. However, the data requested with the query that we need to display the dashboard might as well be sent along with the auth token in the mutation response, saving us an entire round trip to the server and making the application load faster. The problem is that none of the data returned by the mutation will have a representation in the cache yet, so cache.modify
can't help us here.
One option would be cache.writeQuery
, which allows us to add arbitrary data to the cache:
import { useMutation, gql } from '@apollo/client';
// Hard-coded parameters for simplicity
const mutation = gql`
mutation {
authenticate(username: "foobar", password: "1234") {
authToken
todos {
id
title
}
}
}
`;
export default () => {
const [mutate] = useMutation(mutation);
return () => (
mutate({
update: (cache, { data }) => {
cache.writeQuery({
query: gql`
todos {
id
title
}
`,
data: {
todos: data.authenticate.todos
}
});
}
})
);
};
This works, but as I'm not a huge fan of the verbosity of cache updates using graphql queries and I prefer using the same API for similar things, I've added the newFields
option to apollo-augmented-hooks
' modifiers
, and since it is used essentially just like the already familiar fields
option, the solution becomes quite straightforward:
import { useMutation } from 'apollo-augmented-hooks';
// Hard-coded parameters for simplicity
const mutation = `
mutation {
authenticate(username: "foobar", password: "1234") {
authToken
todos {
id
title
}
}
}
`;
export default () => {
const [mutate] = useMutation(mutation);
return () => (
mutate({
modifiers: [{
newFields: {
todos: ({ item, toReference }) => (
item.todos.map(toReference)
)
}
}]
})
);
};