loading...
AeroGear

Automatically Update Apollo Cache after Mutations

stephencoady profile image Stephen Coady ・6 min read

Background

The application cache provides developers with a complete local state and the ability to work seamlessly in offline situations. It also allows us to minimise the load on our server. However, with this increased power we inherit increased complexity and corner cases where our application may no longer perform as expected, possibly containing outdated and irrelevant data.

In Apollo Client 2.0 developers can use the InMemoryCache package to provide their client with a fully featured cache solution. This cache can be used to resolve data locally or to reduce the number of calls to your server when you do not need to replicate everything the server knows about. The cache is normalised and provides a GraphQL API under which Queries and Mutations are stored. This is a developer-friendly way to organise data.

A high-level diagram of a typical Apollo Client cache can be seen below.

Apollo Cache Diagram

In the above example, we see that the Apollo Client queries or modifies data from one of two places. Depending on how you have configured your client it will either check the cache first to see if it contains the data you are or looking for, or it will ask the server and then tell the cache about the result. This is the foundation of implementing an offline application where your client can now query and mutate data locally without interacting with a server.

Apollo Client by default tries to keep its cache up to date. As an example, let's pretend we have a getItems Query in our cache which contains a list of items and each of these items has a title field. If we (through a mutation) update the title field of one of these items, then Apollo will ensure that our getItems Query is also updated. This ensures that the next time we run getItems (if, for example, you have a slow or non-existent network connection) we get the most up to date information.

The Problem

This trivial example is handled quite nicely by Apollo. However, this is because Apollo already knows about this object we have updated. What if it is a new object we have just created? Or if we delete an object and need to dereference it everywhere? These cases are a little less trivial. When I began working with Apollo Client and the cache I immediately noticed some common pain points associated with dealing with these use cases. Let's explore how these more complex scenarios are handled and why doing it inside my own application led me to create a generic package which the community could benefit from.

Manual Cache Updates

To handle anything outside of basic cache updates, Apollo gives us the ability to provide our client with custom update functions. If we use the basic "ToDo" app example and we want to add a newly created task to our cache, the code will look something like the following:

apollo.mutate({
  mutation: createTaskMutation,
  variables: item,
  update: (cache, { data }) => {
    try {
      let { allTasks } = cache.readQuery({ query: getTasks });
      allTasks.push(data);
      cache.writeQuery({
        query: getTasks,
        data: {
            'allTasks': allTasks
        }
      });
    } catch (e) {
        // We should always catch here,
        // as the cache may be empty or the query may fail
    }
});

Let's walk through this example and explain what is happening. We have a (prebuilt) mutation named createTaskMutation, and our update function which accepts a cache object and also the returned data object from the result of our mutation. This function then attempts to do three things:

  1. Read the prebuilt getTasks Query from our cache.
  2. Push the new data object to our current list of tasks
  3. Write that query back to the cache with the new data now contained within.

Once this update function has run we will have our updated data locally in our cache. The perfect solution! Not really.

Introducing Complexity

Now that we know how the update function works with a trivial case we can explore something more realistic. What about when we introduce subscriptions or multiple queries that need to be updated?

Subscriptions

If our application is also subscribed to getTasks it is quite possible that the subscription data will return before the result of the mutation. This means the result of the mutation we created could already be in our cache. This instantly makes the above update function much more complicated, as in its current state our allTasks object in our cache will now contain our new task twice. In other words, we need to make sure deduplication is performed on our cache.

We need to adapt the update function to the following:

apollo.mutate({
  mutation: createTaskMutation,
  variables: item,
  update: (cache, { data }) => {
    try {
      let { allTasks } = cache.readQuery({ query: getTasks });
      if (allTasks) {
        if (!allTasks.find(task => task.id === data.id)) {
          allTasks.push(data);
        }
      } else {
        allTasks = [data];
      }
      cache.writeQuery({
        query: getTasks,
        data: {
            'allTasks': allTasks
        }
      });
    } catch (e) {
        // We should always catch here,
        // as the cache may be empty or the query may fail
    }
});

As you can see, we read from the cache but crucially we iterate through its contents before making any changes. If we don't find the result of our mutation already in our cache then we can safely add it.

Multiple Queries

Another problem is when there are multiple Queries in our cache which need to be updated. This can happen when several different Queries return subsets of the same data. For example, if a different Query named userTasks also needs to be updated when a task is added the code will resemble something similar to the following:

apollo.mutate({
  mutation: createTaskMutation,
  variables: item,
  update: (cache, { data }) => {
    try {
      let { allTasks } = cache.readQuery({ query: getTasks });
      if (allTasks) {
        if (!allTasks.find(task => task.id === data.id)) {
          allTasks.push(data);
        }
      } else {
        allTasks = [data];
      }
      cache.writeQuery({
        query: getTasks,
        data: {
            'allTasks': allTasks
        }
      });
      let { userTasks } = cache.readQuery({ query: userTasks });
      if (userTasks) {
        if (!userTasks.find(task => task.id === data.id)) {
          userTasks.push(data);
        }
      } else {
        userTasks = [data];
      }
      cache.writeQuery({
        query: userTasks,
        data: {
            'userTasks': userTasks
        }
      });
    } catch (e) {
        // We should always catch here,
        // as the cache may be empty or the query may fail
    }
});

In the above example we have two queries we need to update, both of which should contain our new ToDo item. Our update function is now becoming incredibly obtuse and is filled with a lot of boilerplate code. Keep in mind that all of this boilerplate is for one mutation. What about when our application contains several mutations and this boilerplate needs to be placed in several different places?

Out of the Box Cache Helpers

Instead of requiring this boilerplate with essentially the same basic three steps each time of read, append, write, I thought it would be a good idea to extract the logic to a separate helper package. These helpers provide a way to generate mutation options in a way that is compatible with Apollo's mutate API. This means for very little overhead developers get the above update function built automatically for any mutation they wish.

Let's take a look at how we can use one of these functions to make sure the cache is kept completely up to date after a mutation. First, we need to install the package:

npm i offix-cache

We can then use it within our application like so:

const { createMutationOptions } = require('offix-cache');

const mutationOptions = {
  mutation: createTaskMutation,
  variables: item,
  updateQuery: {
    query: getTasks
  }
};

const generatedOptions = createMutationOptions(mutationOptions);

apollo.mutate(generatedOptions);

In the example above we need to provide the Query we want updated (in this case getTasks) when the mutation result returns. The generatedOptions object returned then contains an update function which automatically does the deduplication we saw in a previous code example.

Extending this a bit further - what about if, like we previously mentioned, we have several Queries we want to update once our mutation result returns? The API also accepts an array of Queries, so we can pass any which need to be updated. The mutationOptions object then looks like the following:

const mutationOptions = {
  mutation: createTaskMutation,
  variables: item,
  updateQuery: [
    {
      query: getTasks
    },
    {
      query: userTasks,
      variables: { user : 1 }
    }
  ]
};

Once we pass this to createMutationOptions we will then have a set of mutation options where our update function will automatically update several Queries upon receiving a result, all for very low overhead.

In a later post, I will also detail some other nice features which may be of use, like automatic optimistic response generation and subscription helpers. For now, if you would like to see some more advanced use-cases please take a look at the project's readme. Or test it out on npm.

Discussion

pic
Editor guide
Collapse
wanzulfikri profile image
wanzulfikri

Great writeup. My use case for update is on the trivial side but it’s good to know that I can read this post if I ever need to write more complex update.

Thank you for writing this.

Collapse
pau1fitz profile image
Paul Fitzgerald

One thing I noticed after going through the code in more detail. I think this line is incorrect, at least in my case. data is what is created by the mutation, not the cached array. Have I misunderstood something or is this correct?

else {
  allTasks = [data];
}
Collapse
wtrocki profile image
Wojtek Trocki

Yes. This is correct. Offix helper does that now

Collapse
pau1fitz profile image
Paul Fitzgerald

Thanks for this. Have just introduced subscriptions to my app and was wondering where the extra data was coming from. You saved my bacon 😁

Collapse
khaoptimist97 profile image
khaoptimist97

In my case I want to update just one item instead of the whole. Is there some tips to do that while I dont need to query all the tasks and find by ID the item I want to update.

Collapse
wtrocki profile image
Wojtek Trocki

Hi. Apollo client will do that for you. All updates will happen in the normalized cache and they are already linked to the query object.
The only add and delete operation needs to be handled.

TL;DR - no update needed for edits