Tutorials on Optimistic Uis

Learn about Optimistic Uis from fellow newline community members!

  • React
  • Angular
  • Vue
  • Svelte
  • NextJS
  • Redux
  • Apollo
  • Storybook
  • D3
  • Testing Library
  • JavaScript
  • TypeScript
  • Node.js
  • Deno
  • Rust
  • Python
  • GraphQL
  • React
  • Angular
  • Vue
  • Svelte
  • NextJS
  • Redux
  • Apollo
  • Storybook
  • D3
  • Testing Library
  • JavaScript
  • TypeScript
  • Node.js
  • Deno
  • Rust
  • Python
  • GraphQL

Optimistic UIs with React, Apollo Client and TypeScript (Part III) - Handling Errors

Disclaimer - Please read the second part of this blog post here before proceeding. It walks through the steps taken to update a UI optimistically with Apollo Client. However, it does not discuss how to elegantly handle failed mutations, which, by default, automatically undo optimistic updates made to the UI. If sending a message within a messages client causes the UI to update optimistically with this message, then anytime the server encounters an error (e.g., network or GraphQL) while performing the mutation, the message instantly disappears from the UI. For the user to resend the message, they must retype the message in the input field and resend it. Another problem that arises from reverting the optimistic UI update is the loss of the original timestamp the message was sent at since Apollo Client automatically removes the optimistic data from the cache. In the final part of this tutorial series, we will take the messages client that has been built over the course of the past two parts of this tutorial series and implement a more convenient way for users to resend unsent messages (as a result of a network or GraphQL error). When an error occurs during the mutation, the UI should... By the end of this tutorial, you will have recreated a robust optimistic UI that is capable of resending unsent messages like the one found in Messages : To get started for this tutorial, download the project from the part-2 branch of the GitHub repository here and follow the directions in the README.md file to set up the project. One thing you will notice in the codebase is the template literals passed to the gql tags. Each template literal encloses a GraphQL query or mutation that is executed within a component of the application. Let's move these template literal tags out into a separate module and export each as a named export: ( src/graphql/fragments.ts ) Then, visit each component's source file, and anytime you come across one of these template literal tags, import its corresponding named export and replace it accordingly with the imported value. By refactoring these template literal tags, we can centralize all of the template literal tags in a single place. We import gql from the @apollo/client library just once within the entire application (in the src/graphql/fragments.ts file), and we can reuse these template literal tags anywhere in the application. To understand the general strategy we will take to bypass the UI's instantaneous undoing of the optimistic update, we must dive deep into how the Apollo Client handles an optimistic UI update within the in-memory cache. Disclaimer : At the time of this writing, the current version of the Apollo Client library ( @apollo/client ) is v3.5.8. If you are reading this several months/years after the original publication date, then the underlying architecture may have changed. In part 2 of this tutorial series , I mentioned that the Apollo Client creates and stores a separate, optimistic version of the message in the cache. Here, "optimistic version" refers to an optimistic data layer ( Layer layer) that Apollo Client creates on top of the Stump and Root layers of the cache. Each layer of the cache is responsible for managing its own data, whether it is data associated with an optimistic UI update ( Layer layer) or queries ( Root layer). Partitioning data this way makes it easy to identify which set of optimistic changes (made to the cache's data) to undo when the GraphQL API server returns the result of a mutation. When you inspect the optimistic layer via a JavaScript debugger in the developer tools, you will find that the layers reference each other via a parent property. The deeply nested Root layer holds all the data associated with queries (i.e., the messages seen in the messages client), the Stump layer holds no data and the optimistic layer ( Layer layer) holds all data associated with an optimistic UI update (i.e., the sent message). The Stump layer serves as a buffer (between the optimistic data layers and root data layer) that allows subsequent optimistic updates to invalidate the cached results of previous optimistic updates. With all the Layer layers sharing the Stump layer, all optimistic reads read through this layer. As a buffer, no data is ever written to this layer, and look up and merge calls skip over this layer and get forwarded directly to the Root layer. Note : For more information, please read the pull request that introduced the Stump layer to the cache. Whenever the mutate function is called, the Apollo Client checks if an optimisticResponse option is provided for the mutation. If so, then the Apollo Client marks the mutation as optimistic and wraps the optimistic write within a transaction . When performing this transaction , the Apollo Client adds a new optimistic data layer to the cache . Notice how there are twenty-one messages on the optimistic data layer (the twenty original messages queried from the GraphQL API server plus the message added via the optimistic update) and twenty messages on the root data layer (the twenty original messages queried from the GraphQL API server). Once the cache finishes updating with the new optimistic data layer, broadcastQueries gets called. All active queries listening to changes to the cache will update, which causes the UI to also update. Since broadcastQueries is an asynchronous operation, the UI may not immediately update with the optimistic data even if the debugger has moved on to the next breakpoint. By isolating all of the optimistic updates (carried out by this transaction) to this layer, the Apollo Client never merges the optimistic data with the cache's root-level data. This ensures that the optimistic updates can be easily undone, such as on a mutation error , by deleting the optimistic data layer and readjusting the remaining optimistic data layers (from other pending mutations) , all without ever touching the root-level data. If the mutation fails (or succeeds), then by calling broadcastQueries , the Apollo Client updates the active queries based on recent updates made to the cache, which no longer has the optimistic data layer for the addMessage mutation. This removes the sent message from the UI. Now that we know how the cache works, let's devise a solution that keeps the sent message shown even when the addMessage mutation fails. Given that the first broadcastQueries call updates the UI with the optimistic data (the sent message) and the last broadcastQueries call undoes updates to the UI that involve the optimistic data, we need to add the sent message to the cache's root-level data at the moment the mutation fails between these two calls. This duplicate message will have an identifier of ERROR/<UUID> to... While both pieces of data will exist at the same time, only the message on the optimistic data layer, not the duplicate message on the root data layer, will be rendered. Only the message on the optimistic data layer existed within the cache at the time of the first broadcastQueries call. By adding it to the root data layer, the duplicate message will still exist by the time the last broadcastQueries call occurs. However, since the optimistic data layer gets removed just before this broadcastQueries call, the message on the optimistic data layer will no longer exist. Both messages contain the same text. Hence, nothing seems to change on the UI. The user never sees the optimistic data disappear. Both messages never get rendered together. For us to write the duplicate message to the cache, we must add an onError link to the Apollo Client's chain of link objects. The onError link listens for networking and GraphQL errors during a GraphQL operation (e.g., a query or mutation) and runs a callback upon encountering a networking/GraphQL error. Currently, the Apollo Client already uses the HTTPLink link to send a GraphQL operation to a GraphQL API server that performs it and responds back with either a result or error. Since each link represents a piece of logic to apply to a GraphQL operation, we must connect this link with the onError link to create a single link chain. Let's do this with the from method, which additively composes the links. As a terminating link, the HTTPLink link ends up being the last link in the chain. Defining the links in this order allows the server's response to bubble back up to the onError link. Within the callback passed to the onError link, we can check for any networking/GraphQL errors. If the response is successful, then the onError link simply ignores the response's data as it makes its way to the cache. Shortly after the first broadcastQueries call, the addMessage mutate function executes getObservableFromLink , which obtains the observable of the Apollo Client's link. Unlike promises, observables are lazily evaluated, support array-like methods (e.g., map and filter ) and can push multiple values. Then, the addMessage mutate function invokes the observable by subscribing to it. Essentially, invoking the observable of the HTTPLink link sends a request for this mutation to the GraphQL API server. Note : If you're unfamiliar with observables, you can learn more about them here . Developers who have worked with Angular and/or RxJS have likely previously come across observables. If the Apollo Client encounters a networking/GraphQL error, then the onError link's callback gets called. This callback logs the caught error and checks the name of the GraphQL operation. If the name of the GraphQL operation happens to be AddMessage , which corresponds to the AddMessage mutation, then the Apollo Client adds a message with the same text and sender as the originally sent message to the root data layer of the cache. We create this message based on the variable values provided to the mutation: text and userId . Note : We can pass true as the second argument to the readQuery method ( const { messages } = cache.readQuery({ query: GET_MESSAGES }) ) to include the optimistic data (i.e., the message on the optimistic data layer) in the list of queried messages. However, there are some caveats to this approach. if there are multiple ongoing AddMessage mutations, then the list will also include the optimistic data from those mutations. Therefore, filtering the messages by text and userId to find the optimistically created message is unreliable, especially if the user has just sent several, pending, consecutive messages with the same text. This makes it difficult to modify the optimistically created message's id to follow the ERROR/<UUID> format. Also, because there is a very small discrepancy between this message's timestamp and the timestamp recorded within the callback, the ordering of the messages is unaffected. Finally, the error callback of the observable receives the error , removes the optimistic data layer associated with the mutation and calls broadcastQueries to update the active queries. Since we added a copy of the message to the cache before the optimistic data layer was removed, the view will still display the message to the user. Within the src/index.tsx file, let's add the onError link to the Apollo Client's link chain, like so: ( src/index.tsx ) In the browser, wait for the application to fully refresh. Select a user. Once the application loads the messages, open the developer tools. Under the network tab of the developer tools, select the "Offline" option under the throttling dropdown to simulate zero internet connectivity. When you try to send a message, the message will remain on the UI even if the mutation fails. Now the user no longer has to re-type the message to send it. Plus, the timestamp at which it was originally sent will be preserved (more or less). Click here for a diagram that visually explains the solution. At this point, the sent messages and unsent messages look identical. Each one has a blue background with white text. To distinguish sent messages from unsent messages, let's add a button next to each unsent message that could not be sent as a result of a failed mutation. The button will be displayed as a red exclamation point icon. When clicked on, a dialog will pop open, and it will ask the user for confirmation to resend the message (at its original createdAt timestamp). Within the JSX of the <MessagesClient /> component, next to the <p /> element with the message's text, we need to check if the message's sender is the current user (since unsent messages displayed in the client belong to the current user) and the message's ID for an ERROR substring. Messages that satisfy these two checks are unsent messages. Note : Optimistically created messages do not have the ERROR substring in their IDs, so they are unaffected by this change. This ERROR substring gets introduced only after a failed mutation. Let's define the handleOnRetry function. It accepts the message as an argument. When executed, the function pops open a dialog that asks the user for confirmation to resend the message. Once the user confirms, the Apollo Client performs the AddMessage mutation, but this time, with the variables isRetry and createdAt . These two optional variables tell the AddMessage mutation's resolver to set the created message's timestamp to the timestamp provided by the createdAt variable, not the server's current timestamp. This ensures the messages are in the correct order the next time the application fetches the list of messages from the server. Visit the Codesandbox for the server here  for the implementation of the AddMessage mutation's resolver. If the mutation successfully completes, then the update callback function gets called with the Apollo Client cache and the result of the mutation. We extract out the message returned for the AddMessage mutation and update the currently cached message with the ID The updateFragment method fetches a Message object with an ID of Message:ERROR/<UUID> and replaces the fetched Message object's ID with the returned message's ID. With this update, the cached message will no longer be recognized as an unsent message. The fragment determines the shape of the fetched data. Within the src/types/fragments.ts file, define and export the CACHE_NEW_MESSAGE_FRAGMENT fragment. ( src/types/fragments.ts ) If the mutation ends up being unsuccessful, then we must prevent the onError link from duplicating the cached unsent message. If we passed an isRetry variable to the AddMessage mutation, then we should skip the duplication. Since we added two optional variables that can be passed to the AddMessage mutation, isRetry and createdAt , let's make several adjustments to account for these variables. Within the src/types/index.ts file, add the optional properties isRetry and createdAt to the AddMessageMutationVariables interface: Within the src/graphql/fragments.ts file, add the isRetry and createdAt variables to the AddMessage mutation string: If you look at the CodeSandbox for the server, then you will notice that... After making these changes, here's how the resend functionality should look: Lastly, let's render a "Delivered" status text beneath the current user's last sent message once the message's corresponding AddMessage mutation successfully completes. Within the <MessagesClient /> component, define the state variable lastDeliveredMessageId and update function setLastDelieveredMessageId with the useState Hook. lastDeliveredMessageId stores the ID of the last message sent by the current user. In the useMutation call, include within the passed options an onCompleted callback. This callback gets called as soon as the mutation's result data is available. Inside this callback, call setLastDeliveredMessageId with the sent message's ID. Within the JSX of the <MessagesClient /> component, place the "Delivered" status text right next to the <div className="mt-0.5" /> element. If the message could not be sent, then display a "Not Delivered" status text. Note : The i index comes from the map method's arguments. Reset the ID anytime the user attempts to send a message. So altogether, here's how everything should look: ( src/components/MessagesClient.tsx ) ( src/graphql/fragments.ts ) ( src/types/index.ts ) ( src/index.tsx ) If you find yourself stuck at any point while working through this tutorial, then feel free to visit the main branch of this GitHub repository here for the code. Try implementing optimistic UI patterns into your applications and reap the benefits of a faster, more fluid user experience. If you want to learn more advanced techniques with Apollo Client, GraphQL, React and TypeScript, then check out Fullstack React with TypeScript and Fullstack Comments with Hasura and React :

Thumbnail Image of Tutorial Optimistic UIs with React, Apollo Client and TypeScript (Part III) - Handling Errors

Optimistic UIs with React, Apollo Client and TypeScript (Part II) - Optimistic Mutation Results

Disclaimer - Please read the first part of this blog post here before proceeding. It walks through the initial steps of building a messages client that fetches messages from a GraphQL API server. If you are already familiar with the basics of Apollo Client, and only want to know how to update a UI optimistically (for mutation results), then download the project from the part-1 branch of the GitHub repository here and follow the directions in the README.md file to set up the project. In the second part of this tutorial series, we will implement the remaining functionality of the messages client: By the end of this tutorial, you will have recreated the optimistic UI found in Messages : For a user to send a message, the message client must send a request to the GraphQL API server to perform an addMessage mutation. Using the text sent by the user, this mutation creates a new message and adds it to the list of messages managed by the server. The addMessage mutation, which is defined in the GraphQL schema below, expects values for the text and userId variables. The text variable holds the new message's text, and the userId variable holds the ID of the user who sent the message. Once it finishes executing the mutation's resolver, the server responds back with the sent message. Unlike Apollo Client's useQuery Hook, which tells a GraphQL API server to perform a query (fetch data), Apollo Client's useMutation Hook tells a GraphQL API server to perform a mutation (modify data). Like the useQuery Hook, the useMutation Hook accepts two arguments: And returns a tuple with a mutate function and a result object: In the above snippet, the mutate function is named addMessage . The mutate function lets you send mutation requests from anywhere within the component. The result object contains the same properties as the result object returned by the useQuery Hook, such as data , loading and error . For the <MessagesClient /> component, the application can ignore this result object from the tuple. Since the mutation should cause the UI to update optimistically, the application does not need to present a loading message to indicate a mutation request being sent and processed. Therefore, it does not need to know when the mutation is in-flight. As for the data returned as a result of a successful mutation and the errors that the mutation may produce, we will handle those later on in this tutorial. The <MessagesClient /> component calls the useMutation Hook and destructures out the mutate function (naming it addMessage ) from the returned tuple. The <MessagesClient /> component contains an input field for the current user to type and send messages. First, let's create a ref and attach it to the <input /> element. The ref gives you access to the text typed into the <input /> element. Then, let's attach an event handler (named handleOnSubmit ) to the input field's parent <form /> element's onSubmit attribute that executes the addMessage mutate function when the user sends a message (submits the form with a non-empty input field). The handler calls addMessage , passing in (as the argument) an options object with a variables option, which specifies the values of all the variables required by the mutation. The addMessage mutation requires two variables: Once you finish making these adjustments to the <MessagesClient /> component, run/reload the application. When you send a message, the UI does not update with the message you just sent despite the server successfully processing the mutation request and sending back a response with this message. Typing message into input field: Sending message (submitting the form): Checking the response from the server: To update the UI with this message, we must update the messages stored in the Apollo Client cache. By default, Apollo Client stores the results of GraphQL queries in this local, normalized cache and uses cache APIs, such as cache.writeQuery and cache.modify , to update cached state. Anytime a field within the cache gets modified, queries with this field automatically refresh, which causes the components using these queries to re-render. In this case, the query within the <MessagesClient /> component has a messages field. Once the query resolves and returns with data that has this field (set to a list of messages), like this: Apollo Client normalizes and stores the data in the cache as a flat lookup table by...           As a result, the root level of the cache serves as a flat lookup table. To see this in action, add the update caching option to the addMessages options argument, and set this option to a function with two parameters: Apollo Client executes the update function after the addMessage mutation completes. When you log cache , you will see that cache contains metadata and methods available to an instance of InMemoryCache , which was specified as the Apollo Client's cache. Upon further inspection, you will find the fields messages and users (from the query operations) under cache.data.data.ROOT_QUERY . Logging  cache : Inspecting ROOT_QUERY  in the cache: Notice how Apollo Client stores the query responses using a normalization approach. The cache only keeps one copy of each piece of data, adjacent to ROOT_QUERY . This reduces the amount of data redundancy in the cache. Instead of repeating the same user data across every message, each message in the cache references the user by a unique identifier. This lets the Apollo Client to easily locate the user's data in its cache's flat lookup table. When you log mutationResult , you will see the message you just sent. To render this message to the UI, we must add it to the list of messages already cached by the Apollo Client. Any changes made to the cached query results gets broadcasted across the application and re-renders the components with those active queries. Within the update function, check if the mutation has completed and returned the sent message, like so: Then, we will directly modify the value of the cache's messages field with the cache.modify method. This method takes a map of modifier functions based on the fields that should be changed. Each modifier function supplies its field's current cached value as a parameter, and the value returned by this function replaces the field's current value. In this case, the map will only contain a single modifier function for the messages field. Note : cache.modify overwrites fields' values. It does not merge incoming data with fields' values. If you log existingMessagesRefs , then you see that it points to the value of the messages field under ROOT_QUERY (a list of objects with references to the actual messages in the cache's flat lookup table). To add the message to the cache, the modifier function must... Note : The fragment option of the argument passed to the cache.writeFragment method determines the shape of the data to write to the cache. It should match the shape of the data specified by the query. Reload the application. When you send a message, the UI now updates with the message you just sent. However, this UI update happens after , not before , the server successfully processes the mutation request and sends back a response with this message. To better observe this, simulate slow network speeds in the developer console and send a message again. Let's make the UI feel more responsive by optimistically updating it when the user sends a message. Displaying the mutation result before the GraphQL API server sends back a response gives the illusion of a performant UI and keeps the user engaged in the UI without any delays. When the server eventually sends back a response, the result from the server replaces the optimistic result. If the server fails to persist the data, then Apollo Client rollbacks the optimistic UI updates. To optimistically update the UI to show the message immediately after the user sends a message, provide an optimisticResponse option to the addMessages options argument, and set this option to the message that should be added to the cache. The message must be shaped exactly like the message returned by the addMessage mutation, and it must also include id and __typename attributes so that the Apollo Client can