DEV Community

Cover image for How to use apollo client cache for local state management
Patrik
Patrik

Posted on

How to use apollo client cache for local state management

Apollo client is popular tool of choice for communicating with graphQL API. With its build-in cache capability it is also a good choice for state management. Let's take a look at how to perform CRUD actions on an example to-do app, using apollo client cache for local state management.

This walkthrough uses TypeScript, react (18.2.0) and @apollo/client (3.7.1). User interface is purposefully basic to allow us to focus on interacting with the state. It is composed of four parts:

  • AddTodo a component with a text input element for adding to-dos
  • TodoList which displays all TodoItems that will toggle completed state on click
  • ClearCompleted button that removes completed to-dos from cache

Complete code is available on codesandbox

Initial config

Let's start by defining our apollo client using InMemoryCache:

// client.ts
import { ApolloClient, InMemoryCache } from "@apollo/client";

export const client = new ApolloClient({
  cache: new InMemoryCache(),
});

Enter fullscreen mode Exit fullscreen mode

And make it available to react via ApolloProvider:

// App.tsx
export default function App() {
  return (
    <ApolloProvider client={client}>
      <div className="App">
        <h1>Todo list</h1>
        <AddTodo />
        <TodoList />
        <br />
        <ClearCompleted />
      </div>
    </ApolloProvider>
  );
}

Enter fullscreen mode Exit fullscreen mode

Setup initial state

useQuery has default fetch policy set to cache-first. This policy means that apollo client will check the cache before attempting to send a network request to fetch the data. We want to use only a local state (in a cache) without using network, so initial state will need to be written into the cache before client requests the data.

Going back to the client.ts file, write the initial data before the react app is mounted.
Call writeQuery on apollo client instance, defining the query to write into and the value of initial data.

// client.ts
client.writeQuery({
  query: gql`
    query {
      allTodos
    }
  `,
  // initially an empty array
  data: { allTodos: [] }
});
Enter fullscreen mode Exit fullscreen mode

Structure of the data property must conform the structure of the query. In the code above, query defining allTodos field expects data object containing property with the same name.

If the data property does not contain all the fields defined in the query, apollo will log the error in a console

This call creates canonical field allTodos on the root Query type. We can confirm this by opening apollo devtools and viewing the 'cache' tab.

Apollo devtool showing allTodos field having a value of empty array

Type policies

By default all query writes for allTodos will replace its content. Our goal is to use allTodos field for overwriting all stored to-dos and for adding individual to-dos into the cached array. The default write behaviour can be changed by defining the type policy on the root Query type, specifying allTodos field with the object containing the merge method.

export const client = new ApolloClient({
  cache: new InMemoryCache({
    typePolicies: {
      // type we want to define the policy for
      Query: {
        fields: {
          // field for which we want to customize the behaviour
          allTodos: {
            // how we want to merge existing data with the incoming
            merge(existing = [], incoming) {
              if (Array.isArray(incoming)) {
                return incoming;
              }

              return [...existing, incoming];
            }
          }
        }
      }
    }
  })
});
Enter fullscreen mode Exit fullscreen mode

Value returned by merge will be stored as a new value for allTodos. It will be invoked for all the query writes into allTodos field, even for the initial state write.

merge defined above returns the incoming value as-is if it is array, overwriting previously cached value which was our first goal for this field. In any other case incoming will be returned as a last item in an array of existing data.

Apollo suggests that merge function is 'pure' - not mutating the input arguments since internally the objects are passed as references and mutating them can cause unexpected side-effects.

Adding a to-do item

Adding a to-do is done similarly to setting the initial state. This time the query will define all the fields that will construct the to-do object.

// AddTodo.tsx
const todoQuery = gql`
  query {
    allTodos {
      __typename
      id
      title
      completed
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

Including the __typename is necessary for data normalization in apollo. If you are not familiar with this concept I recommend learning more about it from this article. In short - it allows deduplication of data, enables recognition of correct type for using type policies, updating objects using writeFragment and other useful stuff.

After our todoQuery is defined we will use it in a helper function which is invoked when you press "Add Todo"

// AddTodo.tsx
function addTodo(client: ApolloClient, title: string) {
  client.writeQuery({
    query: todoQuery,
    data: {
      allTodos: {
        __typename: "Todo",
        id: crypto.randomUUID(),
        title,
        completed: false
      }
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

As before it is necessary for the structure of the data to match the structure of the query. __typename is set to Todo which will be important later when we get to updating the completed state.

You have probably noticed that for value of allTodos we have set a single object and not an array as before when writing the initial state. This is when the merge field policy on allTodos that was defined earlier comes in useful. Let's review it again:

// client.tsx
    merge(existing = [], incoming) {
      if (Array.isArray(incoming)) {
        return incoming;
      }
      return [...existing, incoming];
    }
Enter fullscreen mode Exit fullscreen mode

existing is always the current value of the field and incoming will be set to the value of the to-do object we want to add using writeQuery. incoming fails the isArray test and is appended to the array of existing to-dos - in simpler terms: new to-do is added to the end of the list of existing to-dos.

Query To-dos

If you have used react apollo client, the following code will be familiar. Requesting data from cache is the same as requesting them from graphql server. First you define the query and then use it as a first argument in useQuery hook

// TodoList.tsx
const allTodosQuery = gql`
  query getAllTodos {
    allTodos {
      id
      title
      completed
    }
  }
`;

export function TodoList() {
  const { data } = useQuery(allTodosQuery);
  // data: { allTodos: [{...}] }
  /* rest of the component body */
}
Enter fullscreen mode Exit fullscreen mode

The default fetch policy for useQuery (cache-first) means that cache will be queried for data and it returns the empty array that was setup as initial state.

If cache had no data for this query it will attempt to request if from the server. We can prevent this behaviour by changing the fetchPolicy to cache-only.

Additionally to getting the data from cache, useQuery will set up subscription on the cached data. This means that whenever the value of allTodos is changed, component will be re-rendered with the latest cached value.

Now that TodoList can get the data from cache it can render individual items as TodoItem components so now we can see the them in the UI.

Updating to-do

TodoItem takes to-do object as a prop and display its data. In this component we will want to add a functionality of toggling the completed state when user clicks on it. We will use writeFragment method to-do the update, which will require a fragment on a type that we want to edit:

// TodoItem.tsx
const fragment = gql`
  fragment ToggleComplete on Todo {
    completed
  }
`;
Enter fullscreen mode Exit fullscreen mode

This code creates a fragment on Todo type, which matches the value of __typename we have used when adding to-do.

In a fragment we need to specify the properties that we will want to change, in this case it is just completed field. As a next step we will define toggleComplete helper that we will call when user clicks on the to-do:

// TodoItem.tsx
function toggleComplete(client, todo) {
  client.writeFragment({
    id: client.cache.identify({ __typename: "Todo", ...todo }),
    fragment,
    data: {
      completed: !todo.completed
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Notice the client.cache.identify({ __typename: "Todo", ...todo }) utility used that reads the object id and __typename. The output will look something like: Todo:123. This is the 'ref' of the normalized object stored in a cache which tells the cache what object will be updated.

Now when you click the to-do item and toggleComplete is called, it will update the completed property of the to-do object in cache. Cache change in turn triggers subscription set by useQuery in TodoList causing it to re-render with the new data that are being passed as a props into TodoItem. In the UI you will see to-do item that was clicked get crossed out.

Deleting Todos

To complete the CRUD implementation we will add delete functionality of the completed to-dos.

In order to delete completed to-dos we need to get the list of to-dos, filter out the completed ones and write the new list back. All of this can be done with updateQuery method, so let's start by defining the query:

// ClearCompleted.tsx
const queryTodos = gql`
  query Todos {
    allTodos {
      id
      completed
    }
  }
`;
Enter fullscreen mode Exit fullscreen mode

The update needs id so cache can recognize what to-dos are written back and completed to let us filter out completed to-dos.

Update helper will look like this:

function clearCompletedTodos(client: ApolloClient) {
  client.cache.updateQuery(
    { query: queryTodos },
    (data) => {
      return { allTodos: data.allTodos.filter(({ completed }) => completed === false) };
    }
  );
  client.cache.gc();
}
Enter fullscreen mode Exit fullscreen mode

This time the function signature is a little different. In a first parameter query must be defined in an object and the second parameter is update function. It receives data from the query in a shape as requested and it is expected to return the data we would like to have stored on that query. Code above returns object with allTodos property (again matching the shape of the query) with filtered to-dos. This update, same as previous writes, will invoke the merge field policy and because we are writing an array it replaces all of its content.

If you're wondering how are the titles of the to-dos preserved when it is not requested in the update query, it is because of the data normalization that happens internally. The to-do objects that are written back to the query contain id and __typename - which is requested implicitly, and those two properties are enough to match the object to normalized version stored in cache.

After the update query, cache garbage collection is invoked to clean up now unreachable normalized objects.

This currently doesn't work as expected so I have logged this issue in apollo client repo.

Summary

We've covered the cache policies and when they are invoked, setting up the initial state, writing objects into an array and the importance of __typename, querying the data from cache, using fragment to update the normalized object and finally deleting the completed to-dos.

Thank you for reading this article, I hope you found it helpful. When you have a minute to spare I would love to hear your feedback on the information in the article, whether you have learned something new or any suggestions how to improve in my future writing.

Top comments (0)