1. 13
    Use optimistic updates to build UIs that react immediately to user actions
    8m 12s

Use optimistic updates to build UIs that react immediately to user actions

Rares Matei
InstructorRares Matei

Share this video with your friends

Send Tweet
Published 2 years ago
Updated 2 years ago

In this lesson, we're going to explore the "optimisticResponse" option of Apollo's useMutation hook. Optimistic updates allow us to react immediately in the UI, as if the mutation succeeded. This provides a quick/responsive feeling to the user. Once we get the official response from the server, if it was successful, we leave the UI alone, because it should be unchanged, but if there was an error we need to rollback the optimistic updates we applied. This is where Apollo makes it very easy: as we'll explore in the lesson, Apollo has an optimistic cache layering mechanism, that automatically knows how to rollback any failed updates.

Instructor: [0:01] I added a console.log() here to our mutationUpdate function. I'll slow down the network, and I'm going to click Delete on this first note. A network request starts up. Notice how nothing happens for a few seconds. Then the request completes, and our note disappears.

[0:20] We see our object logged here once all of that happens. That's a bit of a weird experience for users on slower networks because they click Delete, and after a few seconds, then they see their result.

[0:35] Our update function only gets called after the mutation completes. Apollo allows us to define an optimisticResponse. This will be a function from which we can return what mutation result we expect if it is successful.

[0:53] If we go back to our query, if this mutation is successful, because we requested this information from it, we expect it to return an object with a deleteNote property, which has a successful flag of true, and it's going to contain a note with the ID of the note that was deleted.

[1:11] That's exactly the object which we constructed here. How do we get this note ID, though? This function will be called with the variables that are sent to this mutation, and on my variables, I expect a noteId. I can just grab it in here. What is this? This mutation should be successful most times if our backend is working correctly.

[1:37] If it's successful, we expect the real result it returns to be something like this. In order to make the UI feel snappier for the user and make it react immediately whenever they do an action, like deleting a note, we're just going to assume that the mutation will be successful and optimistically update the UI straightaway.

[1:58] That's why, whenever we add this option, our update handler will be called twice. The first time, it's going to be called immediately when the user clicks Delete, and this mutation result will be whatever we return here. This gives us a chance to modify the cache straightaway and reflect the changes in the UI immediately.

[2:17] Then, when the mutation actually finishes on the backend, and our network request comes back, the update function will be called a second time, this time with the real mutationResult. This gives us a chance to commit the true result from our backend into our cache and correct any potential inconsistencies caused by our initial optimisticResponse.

[2:40] If we try this out, and we click Delete, the note still stays here. After a few seconds, it goes away, so it didn't actually work.

[2:49] We did get two console.logs() here. As we expect, the first one is for the optimistic update. The second one is when the network request actually comes back. We see that the deletedNoteId for our optimistic update is undefined. Our filter function, which filters out the deletedNote, won't actually know what to filter out in the optimistic phase.

[3:12] Let's see what result we get when our mutation comes back for real on the backend. We do get a successful flag of true with a note, which has the ID of the deletedNote. Look at this, we also get a typename for our deleteNote object, and we also get a typename for our note.

[3:32] Remember, these get sent by our backend based on our GraphQL schema. If you remember, Apollo generates cache IDs by combining this typename and the entity ID that our note has in our database. We need to add proper typenames and our optimisticResponse as well. These are going to help our identify function generate a deletedNoteId.

[4:00] If I try this, and I click Delete, the note disappears straightaway. After a few seconds, we also get the second console.log(). We can see that the deletedNoteId is now correctly identified, even in the first optimistic call, but now, our real response right in here is exactly the same as our optimisticResponse up here. This is the best-case scenario.

[4:27] What happens if there's an error on the backend while trying to delete a note? I set up my server to throw an error if I try to delete this last note here. If I press Delete, it optimistically disappears. After a few seconds, we get an error, "Unhandled rejection."

[4:49] That's because mutation functions in Apollo return promises. When promises blow up in your React components, we see errors like that. I'm just going to add a catch statement to this. If I go back to my browser, and I click Delete on this, it optimistically disappears. After a few seconds, it comes back up. Let's try that again.

[5:15] It disappears. Then, when the server tells us, "Nope, I actually couldn't delete that note for you," Apollo brings it back into the cache, and we see it in our UI again. If we look at the mutation response, we can see that we have an Errors object next to our data. That's how Apollo knows that the mutation wasn't successful.

[5:36] To summarize, we're assuming operations like Delete will work most of the time, so we're optimistically reacting in the UI to make the experience feel as quick as possible for the user. If there's an error, and the note remains on the backend, instead of keeping the UI inconsistent, Apollo rolls back the update and just adds the note back in. Pretty cool.

[6:02] In our first call to update the optimistic call, we're completely removing the note from the cache. How does Apollo know to bring it back when the mutation fails? We've not told it anywhere how to roll back our deletion. Let's say we have our Apollo cache. Our Notes List component can ask for a list of notes from it. Our Notes Edit component can ask it for a specific note.

[6:27] If we trigger a mutation with an optimisticResponse defined, Apollo will create a new cache layer, an optimistic cache. Any updates we make to the cache are only going to be applied to this upper cache layer, not to the main cache. Then, when we remove our note from the list, when the Notes List component asks for the list of notes, it gets the new list we modified, our Optimistic Notes List.

[6:54] If our Edit Note component asks for a note, our optimistic cache won't have it. It redirects it back to the main cache, which will have it. We can imagine our optimistic updates as a series of layers with different updates that delegate to each other all the way down to the main cache.

[7:15] If the mutation is successful, the optimistic updates will just persist back to the main cache. If there's an error, however, Apollo can just completely discard the optimistic layer and redirect all components back to the main cache. That's why it's so easy for Apollo to just roll back any failed updates.

[7:35] In summary, just by adding an optimisticResponse, we get a snappy UI that reacts immediately to user updates. If the mutation fails, Apollo just rolls back our optimistic modification so that we're not left in an inconsistent state with the backend.

[7:54] This optimisticResponse needs to have a shape similar to the real mutation result we expect from the backend, including correct typenames. This is so our optimistic objects can be correctly identified and cached by Apollo.