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 :)
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 :)