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 allTodoItem
s 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(),
});
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>
);
}
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: [] }
});
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.
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];
}
}
}
}
}
})
});
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
}
}
`;
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
}
}
});
}
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];
}
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 */
}
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
}
`;
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
}
});
}
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
}
}
`;
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();
}
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)