I'm very fond of the products developed by the Apollo team, specially the Apollo Client. It started as a simple tool to connect your frontend data requirements with a GraphQL server but, nowadays, it's a complete solution for data management on the client-side. Even though using GraphQL potentially reduces the need for local state, Apollo comes with a redux-like "global" store that you access it with a GraphQL interface. If you want to know more about how to implement client-state with Apollo, read here.
I don't want to talk about local resolvers or frontend-only state, but the underlying cache that Apollo uses when you fetch GraphQL from the server, specially how you can leverage it to make your UIs more responsive and smart. Apollo docs on how to interact with cache is a good resource but, IMO, a bit far from real use cases.
Simple CRUD
I'll create a simple Wishlist React app (though most of what is shown is applicable to other frameworks), that is simply a CRUD of Items { title: string, price: int }
. Like most real apps it has a list of resources, a delete button, a edit form and an add form.What I want to show is, basically, how to update the list after a mutation without having to refetch the list from the server.
I'll be using this simple server that I've created with Codesandbox (man, aren't these guys awesome?) and the only thing to notice is that, on the edit mutation, your server should return the updated resource, you'll know why later. I'll add some mock data for us to see the results right-away.
I'll assume that you already know how to setup an Apollo Client + React Apollo application. If not, check it here.
So, I'll go over every letter on CRUD (for educational purposes, RUCD) and explain what you may need to do to use.
Read
const GET_ITEMS = gql`
{
items {
id
title
price
}
}
`;
function Wishlist() {
const { loading, error, data } = useQuery(GET_ITEMS);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error :(</p>;
return data.items.map(item => {
const { id, title, price } = item;
return (
<div key={id}>
{title} - for ${price} -{" "}
</div>
);
});
}
This is it! Our read query is very simple: it doesn't take search parameters, pagination metadata or uses cursors. For demonstration purposes it's better this way, but I'll comment later if you need some help with more complex cases. Just with this setup, we're already prepared for our list to react to cache changes on subsequent writes.
Note: Using useQuery
, <Query>
or even withQuery
is fundamental here, since it links the data with the cache. If you use other solution like useApolloClient
and, then, client.query
, these solutions will not work for you.
Edit
We will create our Edit component and this one is the easiest case because you don't need to do anything for the list to auto update after you edit the Item. Apollo Cache, by default, uses the pair __typename
+ id
(or _id
) as a primary key to every cache object. Every time the server returns data, Apollo checks if this new data replaces something that it has on its cache, so, if your editItem
mutation returns the complete updated Item
object, Apollo will see that it already has that item on the cache (from when you fetched for the list), update it, and triggers the useQuery
to update the data
, subsequently making our Wishlist
re-render.
const EDIT_ITEM = gql`
mutation($id: Int, $item: ItemInput) {
editItem(id: $id, item: $item) {
id
title
price
}
}
`;
const EditItem = ({ item: { title, price, id } }) => {
const [editItem, { loading }] = useMutation(EDIT_ITEM);
return (
<ItemForm
disabled={loading}
initialPrice={price}
initialTitle={title}
onSubmit={item => {
editItem({
variables: {
id,
item
}
});
}}
/>
);
};
Done! When the user hit the Submit
button on the edit form, the mutation will trigger, the Apollo client will receive the updated data and that item will update automatically on the list.
Create
Let's create our AddItem
component. This time it's a bit different because when we get our response from the server (the newly created Item
) Apollo doesn't "know" that the list should be updated with the new item (and, sometime, it shouldn't). For this we have to programatically add our new item to the list, and one of the parameters of the useMutation
hook is the update
function, that is there specifically for that purpose.
The steps we need to update the cache are:
- Read the data from Apollo cache (we will use the same
GET_ITEMS
query) - Update the list of items pushing our new item
- Write the data back to Apollo cache (also referring to the
GET_ITEMS
query)
After this, Apollo Client will notice that the cache for that query has changed and will also update our Wishlist on the end.
const ADD_ITEM = gql
mutation($item: ItemInput) {
addItem(item: $item) {
id
title
price
}
}
;
const AddItem = () => {
const [addItem, { loading }] = useMutation(ADD_ITEM);
return (
<ItemForm
disabled={loading}
onSubmit={item => {
addItem({
variables: {
item
},
update: (cache, { data: { addItem } }) => {
const data = cache.readQuery({ query: GET_ITEMS });
data.items = [...data.items, addItem];
cache.writeQuery({ query: GET_ITEMS }, data);
}
});
}}
/>
);
};
Delete
The delete case is very similar to the create-one. I will colocate it on the Wishlist component for simplicity, and also add some props/state for the overall functionality of the app.
const DELETE_ITEM = gql
mutation($id: Int) {
deleteItem(id: $id) {
id
title
price
}
}
;
function Wishlist({ onEdit }) {
const { loading, error, data } = useQuery(GET_ITEMS);
const [deleteItem] = useMutation(DELETE_ITEM);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error :(</p>;
return data.items.map(item => {
const { id, title, price } = item;
return (
<div key={id}>
{title} - for ${price} -{" "}
<button
className="dim pointer mr2"
onClick={e => {
onEdit(item);
}}
>
edit
</button>
<button
className="dim pointer"
onClick={() => {
deleteItem({ variables: { id },
update: cache => {
const data = cache.readQuery({ query: GET_ITEMS });
data.items = data.items.filter(({id: itemId}) => itemId !== id);
cache.writeQuery({ query: GET_ITEMS }, data);
}});
}}
>
delete
</button>
</div>
);
});
}
Advanced cases
It's common to have more complex lists on our apps with search, pagination and ordering and, for those, it gets a bit complicated and it heavily depends on the context of your application. For example, when deleting an item of the 4th page of items, should we delete it from the UI and show pageLength - 1
items or fetch one item from the next page and add it?
These cases are also tricky because the cache.readQuery
also need variables if the query
provided receives it, and you might not have it globally available. One option is using something like refetch
from the original query, or refetchQueries
from Apollo, what makes it go on the server again. If you want to dig deeper on this problem, this issue has a lot of options for you, specially a snippet for getting a query's last used variables.
The final solution
The app has some state/UI quirks, I've made the minimal to demonstrate Apollo Cache Update :)
The whole app developed here is on this CodeSandbox. You might need to fork this container and update the server URL.
Feel free to reach me :)
Top comments (13)
Great post!
As per the Apollo-Client docs "Do not modify the return value of readQuery. The same object might be returned to multiple components. To update data in the cache, instead create a replacement object and pass it to writeQuery."
I attempted to use your solution for adding to a list of a users chats on creation and ran into "Error: Cannot assign to read only property 'edges' of object '#'"
Once again, great post, just thought I'd put this here for anyone running into similar issues. You must create a new object, you cannot mutate the return value of cache.readQuery anymore and apollo will throw an error.
This is what the Apollo docs for local state should look like - much easier to follow for real use cases. It seems like it will get a bit convoluted, but not unmanageable, when paired with optimisticResponse since they both interact with the cache. Thanks :)
Helpful post and thanks for sharing. here is my package npmjs.com/package/mutation-cache-u... motivated by you.
Very nice, Ephrem!
Does it updates cache automatically?
I've run into some issues and no one seems to respond either in the apollo community, graphql or redit. The issue is cache update but for a paginated api result. Here's the problem as I wrote it on reddit(reddit.com/r/graphql/comments/frr7...)
I'm running into an issue with cache updates. I've a list of posts fetched using a query FETCH_POSTS which returns an array of type Post. I also have a CREATE_POST and UPDATE_POST mutations which return an item of type Post too. When I create a post, and use writeQuery to update the cache it works ok. The new object will be [...existingPosts, newPost]. When it comes to updating a post. When I readQuery and log the result, it seems to have updated the cache already(I'm guessing it's an auto update since the type and id are the same and exist) so writeQuery in that situation doesn't make sense. The only issue is these updates do not appear on the UI. Any idea why I may be experiencing this? This implementation works when the list of posts is not paginated. But on pagination the edit to the item in the list does not work. This is my query to fetch posts using the connection directive to ignore the pagination params.
const FETCH_POSTS = gql query getPosts($params: FetchPostsParams!, $pagination: PaginationParams) { posts(params: $params, pagination: $pagination) @connection(key: "posts", filter: ["params"]) { ...PostFragment } } ${FRAGMENTS.POST_FRAGMENT} ;
Things like this usually happen because the query is cached paired with the pagination variables... There was a solution I've found on Github Issues years ago where you could retrieve the "last used params" for the pagination, and, with that, your cache update would work just fine.
But, I see it's a simple update, the resolutions algorithm should work just fine, it's the easiest of cases. One thing you can look into is the use of Fragments, I've never used it, so it might be the point of the problem.
Hey Luciano! Will you be interested in a paid writing opportunity in your spare time?
Hey Mary!
How would that work? It depends on the load, I guess...
sure. do you have skype or twitter? my twitter @maryvorontsov
"here" link (apollographql.com/docs/react/data/...) is broken
Great Post.
Thanks for sharing
It helped a lot. Thank you for sharing