Configuring InMemoryCache, or making updates to the cache more tractable

As many of us have experienced with the Apollo Client cache, something as innocuous as adding an item to a list can be an exercise in consternation.

If you’ve used a Cell to fetch a list of data, like a user’s todos for example, then you’ve probably tried to update that data as well. The way you update that data usually looks something like this:

  const [createTodo] = useMutation(CREATE_TODO, {
    update(cache, { data: { createTodo } }) {
      cache.modify({
        fields: {
          todos(existingTodoRefs = []) {
            const createTodoRef = cache.writeFragment({
              data: createTodo,
              fragment: gql`
                fragment CreateTodo on Todo {
                  id
                  type
                }
              `
            });
            return [...existingTodoRefs, createTodoRef];
          }
        }
      });
    }
  });

I basically took the example above straight from the docs. And that’s just one of the ways it could’ve be done (we could’ve used cache.readQuery (or cache.readFragment) and cache.writeQuery (or cache.writeFragment)).

But why do we have to do all this anyway? Why isn’t it just as simple as any other update, where as long as CREATE_TODO returns the id and the modified fields, it’s cached?

I’m going to try to explain 1) why it’s not that way and 2) how we can make it be that way anyway.

Why isn’t it just cached?

The short answer: it is cached, it’s just not cached as part of the todos query because that’s not how normalization works. (Maybe?)

Let’s say we have a Cell that fetches todos and displays them:

// src/components/TodosCell/TodosCell.js

export const QUERY = gql`
  query TodosQuery {
    todos {
      id
      text
    }
  }
`

...

export const Success = ({ todos }) => {
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>&bull; {todo.text}</li>
      ))}
    </ul>
  )
}

Nothing too fancy; at localhost:8910 we just see:

So far so good. Now let’s look at the Apollo Client cache. You could open the Apollo Client dev tools to do this, but I just came across this sweet shortcut–open the console and type:

__APOLLO_CLIENT__.cache.data.data

You should see:

And there’s the cache! Or at least what’s in it, normalized and all. Right now it’s made up of four objects: ROOT_QUERY (our Cell’s todos query ends up here) and three normalized todos (the normalization being all the "Todo:1" you see going on). Notice all three of the normalized todos are part of ROOT_QUERY's todos. They’re all accounted for.

Now let’s add a mutation. We’ll use the useMutation hook to add a todo and see what happens to the cache. We’ll just hard code things to keep it simple:

const CREATE_TODO = gql`
  mutation CreateTodoMutation {
    createTodo(text: "that thing you asked for") {
      id
      text
    }
  }
`

export const Success = ({ todos }) => {
  const [createTodo] = useMutation(CREATE_TODO)

  return (
    <>
      <button onClick={() => createTodo()}>add</button>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>&bull; {todo.text}</li>
        ))}
      </ul>
    </>
  )
}

Alright, now let’s press the button and inspect the cache. The Apollo docs say that the cache isn’t automatically updated when a new item is added to a list, so we’re expecting to see nothing right?

But our new todo is there! Todo:4. So what gives?

Well that Todo:4 isn’t part of the ROOT_QUERY's todos. todos is still just made up of the original three. So it seems like while the new todo was automatically cached, it wasn’t automatically added to the ROOT_QUERY's todos.

As to why not, again I don’t really know; I think it’s because the todos query technically was just those three Todos, and if we’re being super principled about normalized caching, it’d be wrong to assume that just because a new todo was added it’s supposed to be a part of the todos query… ¯_(ツ)_/¯

But we obviously want todos to be updated. As the example we kicked off with showed, we could use an update function, but there’s another way using Apollo Client 3’s TypePolicies.

TypePolicies

One of the major new features in Apollo Client 3 is TypePolicies. They’re kind of like another layer of GraphQL resolvers specifically for your client. We can use them to tell our Apollo Client cache to automatically write created todos to the todos query:

// web/src/cache.js 

import { InMemoryCache } from '@apollo/client'

const cache = new InMemoryCache({
  typePolicies: {
    Mutation: {
      fields: {
        createTodo: {
          merge(_, incoming, { cache }) {
            cache.modify({
              fields: {
                todos(existing = []) {
                  return [...existing, incoming]
                },
              },
            })
            return incoming
          },
        },
      },
    },
  },
})

export default cache

What we’re basically saying is that whenever createTodo is about to write data to the cache (the merge function), instead of just returning the incoming data, we want to update the todos query with that incoming data as well. Now we can call create mutations like update mutations!

Lastly, in web/src/index.js, pass this cache config to the RedwoodProvider's graphQLClientConfig prop:

...

import cache from './cache'

ReactDOM.render(
  <FatalErrorBoundary page={FatalErrorPage}>
    <RedwoodProvider graphQLClientConfig={{ cache }}>
      <Routes />
    </RedwoodProvider>
  </FatalErrorBoundary>,
  document.getElementById('redwood-app')
)

Hope this made sense. If you take away one thing from this post, remember that __APOLLO_CLIENT__.cache.data.data in the console is just :exploding_head:

7 Likes

TIL 1: consternation: “a feeling of anxiety or dismay, typically at something unexpected.”
TIL 2: __APOLLO_CLIENT__.cache.data.data is awesome

:smiley:

Thanks for sharing @dom!

1 Like

Thanks @dom for this great writeup.

What we’re basically saying is that whenever createTodo is about to write data to the cache (the merge function), instead of just returning the incoming data, we want to update the todos query with that incoming data as well.

My question is - what if the data is updated “outside” a client-initiated Mutation?

I have a task that updates data from an incoming API – it’s a job that runs via Repeater every hour.

New data is added --sometimes updated.

How would the client know to update if the user is viewing the page/cell with the old data and it updates underneath the covers?

I read:

And since I know my job runs hourly, I could set the polling interval to be 20/30/50 minutes.

Is that a reasonable approach?

1 Like

@dthyresson seems like the right use-case for Subscriptions as well. You’re not deploying your API directly to AWS, are you?

I’ll look into that as well.

You’re not deploying your API directly to AWS

I plan to deploy a few functions and services to AWS to process long longer running jobs, but since this app is a proof of concept, I was holding out to see if background jobs on Netlify becomes available.

Actually, it would be exactly those particular services that would update the data (the GraphQL API would remain on Netlify).

That said - I may still deploy those to Serverless as an experiment – and likely Netlify background jobs won’t be available in short term.

1 Like

How would the client know to update if the user is viewing the page/cell with the old data and it updates underneath the covers?

Polling interval seems super reasonable! You could also add a button to manually refetch the queries using the refetch function available to cells: Queries - Apollo GraphQL Docs. I think you can just destructure it out of the props, like

export const Success = ({ refetch }) => ...

And as @thedavid said, subscriptions would be super interesting as well.

So what’s the downside of this? Why isn’t this the default behavior for the InMemoryCache?

I’ll draw this up in more detail later, but the gist of it is: if there was more than one way you could’ve issued the todos query, like if it could take arguments (e.g. first ten todos, todos associated with project), right now we’d be writing the new todo to all of those todos queries, which isn’t what we want.

There’s a simple fix for that though–you have to compare the arguments of the create mutation and the query, which Apollo makes it easy to do.

1 Like

That makes sense. Thanks!

For some reason in Chrome I couldn’t get the global __APOLLO_CLIENT__ without the window prefix. In case others encounter the same:

window.__APOLLO_CLIENT__.cache.data.data

1 Like

I came upon this method too, but had to face a type error. (rw v0.33.3)

GraphQLClientConfigProp, the type of graphQLClientConfig does not want the 'cache' field.

export type GraphQLClientConfigProp = Omit<ApolloClientOptions<InMemoryCache>, 'cache'>

I’m guessing it’s because RedwoodApolloProvider is providing an InMemoryCache by default, but the error makes me think if this is NOT what I should do.

I confirmed the code works nonetheless, since it just spreads the graphQLClientConfig object onto the ApolloClient constructor – luckily after providing an default cache field.

If the intent of GraphQLClientConfigProp is to let the cache field be optional, maybe it is better to declare it as Omit<ApolloClientOptions<InMemoryCache>, 'cache'> & { cache?: ApolloCache } or so?

By the way, I currently have extracted the graphQLClientConfig prop as a separate variable to circumvent the error. Some weird typescript trick for this kind of situations…

1 Like

Great article, thanks for sharing this info

1 Like

Hi @dom , I took the approach of TypePolicies, but I am seeing another http round trip after mutation, what could be problem?