Skip to main content

Caching using Apollo GraphQL

The following is an overview of how we use caching, how we could use it in the future, and some good resources for learning more.

Basic usage#

Let's say that we make the following query to the backend:

query {
getClauses {
_id
name {
_id
label
}
}
}

After the initial db call, the result of the query is stored in the browser cache - meaning that the next time we want to get all clauses, we can read from the cache instead of querying the db again. This can be done in two ways:

1. With the useQuery or useLazyQuery hooks#

When using the query hooks we must specify the fetchPolicy of the query to read from the cache - use "cache-only" or "cache-first". If not all the requested data is found in the cache, the query either fails and throws an error ("cache-only") or retries the query on the backend ("cache-first"). The code should look similar to this:

const { data, loading, error } = useQuery(GET_CLAUSES, {
variables: { ids: someIds },
fetchPolicy: 'cache-first',
});

2. Read directly from the cache#

It is possible to skip the useQuery hook altogether, relying instead on the useApolloClient hook which exposes the readQuery and readFragment methods to read from the cache. Note that readQuery does not throw an error if data is missing from the cache, instead it returns null.

import { useApolloClient } from '@apollo/client';
const client = useApolloClient();
const cached = client.readQuery({
query: GET_CLAUSES,
variables: { ids: someIds },
});
if (!cached) throw new Error('Failed to read cache');

readFragment works in a similar way, except that unlike readQuery you are not restricted to executing one of the queries defined in your GraphQL schema. You could for example read just the meta-data from a clause:

const metaData = client.readFragment({
id: 'Clause:5ffcdb885679d2001e0caf63',
fragment: gql`
fragment Meta on Clause {
author
contact
}
`,
});

IDs and typePolicies#

Unless explicitly told otherwise, the cache is updated every time a response from the db is received. This updating and data storage works reasonably well out-of-the-box, but there are a couple of things to note:

Unique identifiers for all objects#

In the cache, each object is stored with a unique identifier of the form {__typename}:{_id} - e.g. Clause:5ffcdb885679d2001e0caf63. It is therefore important that you in all queries to the db remember to query the _id field for all objects that have one - otherwise the cache will fail to index the incoming data correctly and you probably won't be able to access it later.

Merging non-scalar fields#

Lets say that we have a Name object stored in the cache which looks like this:

{ _id:123, label: "some label", clauses: [{clause1}, {clause2}] }

Further suppose that after a useMutation call, the name is updated to no longer point to clause1. The cache receives the new version of the name, and tries to update the cache by merging all the fields in the existing name object and the incoming one. For fields that are scalars - such as name.label - the cache does an equality comparison and updates the string if it has changed. However, for non-scalars such as objects and arrays this comparison is invalid (comparing arrays is in general hard). We must therefore specify some custom behaviour to deal with merges for these fields. This is done using typePolicies, which is passed as a config argument when instantiating the cache. Let's say we want to keep the incoming array but remove any null values. The policy definition would then look like this:

const typePolicies = {
Name: {
fields: {
clauses: {
merge: (existing, incoming) => incoming.filter((i) => i),
},
},
},
};
// Instantiate the client
const client = new ApolloClient({
cache: new InMemoryCache({
typePolicies,
}),
});

Failing to set this particular policy would probably not result in any bugs, as "keep-only-incoming" is the default behaviour of the cache - nonetheless, it will throw a warning at you if you don't supply a policy.

Writing to the cache#

Modifying an object returned by a readQuery call can cause all kinds of trouble - to modify the cache we instead use writeQuery or writeFragment. These methods are also exposed by useApolloClient and are similar to their read counterparts.

const client = useApolloClient();
client.writeQuery({
CHANGE_CLAUSES,
data: {
clauses: newClauses,
},
});

This write can be nested in the update callback of useMutation for convenience - see this example.

Ideas for how to use cache going forward#

Proper use of caching should lead to lighter components that render faster and are easier to memoize. I would suggest the following approach:

  1. Whenever possible, perform db calls asynchronously as high up in the component tree as possible, and as early as possible. This will minimize the perceived slowness of huge queries such as getting the MFN inheritance trees. In order to combat data staleness, we probably want to use polling for these top-level queries. Additionally, we need to consider if cache-writes are needed whenever we perform mutations.

  2. Instead of passing data down the component tree, read from the cache where the data is needed. The trick here is to make sure that we always have the required data available - and have good error handling (db call as a backup solution?) for when we undoubtedly screw this up.

Some key docs from Apollo:#

reading from and writing to the cache Rerunning queries after a mutation typePolicies - dealing with merging new and old objects