May 2, 2017

Tutorial: Speeding up GraphQL Mutations with optimistic UI

Jonas Helfer

Jonas Helfer

This is part 4 of our Full-stack React + GraphQL tutorial series. Each part is self-contained and introduces one new, key concept, so you can either do each part separately or follow the entire series — it’s up to you!

Here are the sections we’ve covered so far:

In this part, we’re going to dive a little bit deeper into how GraphQL mutations are handled on the client. We’ll take the list view and mutation from part 3, simulate a network latency of 500 milliseconds, and then use store updates and Optimistic UI to make the latency all but disappear from the perspective of the user.

By the end of this tutorial, you’ll know how to use store updates and Optimistic UI to deal with network latency.

Hint: If something doesn’t work quite as expected, you can always check out the “solution” by running <em>git checkout t4-end</em>


To get started, let’s clone the tutorial git repo and install the dependencies:

git clone https://github.com/apollographql/graphql-tutorial.git
cd graphql-tutorial
git checkout t4-start
cd server && npm install && cd ../client && npm install && cd ..

To make sure it all worked as intended, let’s start the server and the client each in a separate terminal:

cd server
npm start

In another terminal:

cd client
npm start

If it worked, you should now see the ChannelsView from the end of part 3 in your browser:

Adding a new channel — no latency

Mutations and Latency

As you can see in the image above, any new channels you add show up in the list instantly. That’s great, because reactivity helps with great UX.

However, if you followed along in part 3, you might remember that this update actually does two roundtrips right now! One for the mutation request, and a second one to refetch the list after the mutation completed. The second roundtrip is currently necessary because there’s no way for the client to know that the new item you just added should be in the list. GraphQL simply doesn’t have any way of encoding that information because it treats the backend as a black box: query in → data out. Before we fix that, let’s see why it’s a problem.

In order to see what the UX would be like if the network was slower, we’re going to simulate a network latency of 500ms in Apollo’s network interface by adding a middleware.

To do so, add the following lines to client/src/App.js right after declaring the networkInterface constant.

// const networkInterface = ...
networkInterface.use([{
  applyMiddleware(req, next) {
    setTimeout(next, 500);
  },
}]);

Now that we’ve added 500 ms of latency to every request, adding a new channel feels a bit different:

Adding a new channel — 500 ms latency

The delay you see from the time the word “tennis” fades to the time it disappears is the time it takes for the mutation to complete its server roundtrip. The delay from the time the word “tennis” disappears in the input box until the time it appears is the latency of the list refetch query.

Even though refetching made it really easy and quick for us to build this feature in the first place, we need a better solution in a production app with users that experience latency. Right now, the experience for those users is not great: First, they wait and nothing happens; then, they see their input disappear completely, before it finally appears where they expected it.

Given that especially on mobile networks a few hundred milliseconds of latency is not uncommon, we need a way to deal with it. The first thing we can do is avoid the second roundtrip to refetch the list.

Post-mutation store updates

Lucky for us, the mutation already returns all the information we need to incorporate the new item into the list. We just have to tell Apollo Client how to do it for us!

For mutations and other cases where you need to update the store based on an action on the client, Apollo provides a set of powerful tools to perform imperative store updates: readQuery,writeQueryreadFragment and writeFragment. You can read all about them here.

Because updating the store after a mutation is such a common use-case, Apollo Client makes it really easy to use these functions via the update property exposed in mutate.

To use it, let’s replace the refetchQueries option in AddChannel.js with the following call to update:

const handleKeyUp = (evt) => {
    if (evt.keyCode === 13) {
      evt.persist();
      mutate({ 
        variables: { name: evt.target.value },
        update: (store, { data: { addChannel } }) => {
            // Read the data from the cache for this query.
            const data = store.readQuery({query: channelsListQuery });
            // Add our channel from the mutation to the end.
            data.channels.push(addChannel);
            // Write the data back to the cache.
            store.writeQuery({ query: channelsListQuery, data });
          },
      })
      .then( res => {
        evt.target.value = '';  
      });
    }
  };

Now as soon as the mutation completes, we read the current result for the channelsListQuery out of the store, append the new channel to it, and tell Apollo Client to write it back to the store. That’s all we have to do! Our ChannelsListWithData component automatically will automatically get updated with the new data.

If you run another mutation, you’ll notice that there is no longer any delay between the time the input disappears and re-appears at the end of the list. It’s practically instantaneous, which is great!

Adding a channel with “update”— 500 ms latency

The latency of the initial mutation is still there, but fortunately there’s a way to deal with that, too!

Optimistic UI

Apollo Client can simulate zero-latency server responses with a trick that’s generally called Optimistic UI. Optimistic UI is pretty tricky to set up manually, but thanks to the fact that Apollo manages both the store and the network requests for you, using Optimistic UI is really easy.

All you have to do to simulate a zero-latency server response is add the optimisticResponse property to your mutate call. optimisticResponse should be the response that you expect from the server. We already know the channel name that we expect. The ID isn’t that important yet, so we just take a random value to make sure there are no collisions. Finally, we also have to specify the __typename to make sure Apollo Client knows what kind of object it is:

mutate({ 
  variables: { name: evt.target.value },
  optimisticResponse: {
     addChannel: {
       name: evt.target.value,
       id: Math.round(Math.random() * -1000000),
       __typename: 'Channel',
     },
   },
   update: ...
})

If add the optimisticResponse to your code, you’ll notice that there’s no more delay between the time you hit return and the time the item shows up at the bottom of the list.

In fact, the optimisticResponse is so fast that the item will appear in the list even before it’s removed from the input field. This is because we have a snippet of code that clears the input field when the mutation successfully returned. Now that we have optimistic UI, that’s not really appropriate anymore. Instead, let’s give the user some visual feedback about which list items have already been confirmed by the server, and which ones haven’t.

To let the user know when a channel has not yet been confirmed by the server, we’ll need to somehow add that information to the item. Instead of modifying the server schema to keep track of some client state, we’re going to use a little hack here to keep track of which items are optimistic.

We know that all server-generated ids are positive integers, but the optimistically “generated” ids are all negative — how lucky! 😉

id: Math.round(Math.random() * -1000000),

Note: There are cleaner ways to keep track of optimistic items in your UI, but they require a bit more setup, so we’ll leave that for a future tutorial.

To make the optimistic channels visually distinct, we give them an extra CSS class in ChannelsListWithData.js:

return (
    <div className="channelsList">
      <AddChannel />
      { channels.map( ch => 
        (<div key={ch.id} className={'channel ' + (ch.id < 0 ? 'optimistic' : '')}>{ch.name}</div>)
      )}
    </div>
  );

Finally, we just have to define what that class looks like in App.css. For our purposes, we just make the text a bit more greyed out:

div.optimistic {
  color: rgba(255, 255, 255, 0.5);
}

And that’s it! With these changes, the addChannel mutation should now look much better!

Adding a channel with “update” and “optimisticResponse” — 500 ms latency

Conclusion

And with that, you’ve reached the end of part 4 in this tutorial series! You’ve learned how to use store updates and optimistic UI to hide network latency from your users. Together with Parts 1, 2 and 3 of this tutorial series, you’re now familiar with all the basics of writing a simple React + GraphQL app with great UX using Apollo.

In the next part of this series we’ll add a second view to the app and learn how to use cached data and prefetching to show previews and load pages faster. Make sure to subscribe if you don’t want to miss future parts!

If you liked this tutorial and want to keep learning about Apollo and GraphQL, make sure to click the “Follow” button below, and follow us on Twitter at @apollographql and @helferjs.

Written by

Jonas Helfer

Jonas Helfer

Read more by Jonas Helfer