Automatically handling Apollo Client errors in your React UI

Automatically handling Apollo Client errors in your React UI

There’s a multitude of things that can go wrong at runtime when your client communicates with a GraphQL server. First there’s the possibility of networking problems, then issues with queries that reach your server but are malformed or don’t match the schema. Next, once your server has been able to parse a query successfully, there’s all the things that can go wrong in your resolvers as they attempt to fetch data from upstream sources and massage it into a form that can be sent back to the client.

Other than maybe the odd networking issue, none of these errors are really the fault of your user. Instead, they usually represent a fault on the part of you, the developer. The fault may be a minor code defect, a major UI design failure, or something in-between. Whatever the case, there’s usually very little the user can do about it. Consequently, often the best you can do for your user when a problem occurs in your app is let them know as soon as possible, maybe suggest that they try again later in case it is an intermittent problem, and encourage them to let you know if the problem persists.

In this post I’ll demonstrate how to build a React UI so that the user is notified whenever any error is encountered by Apollo Client. Then I’ll show how this technique can be adapted to work with partially-returned datasets, where some types of error are considered acceptable. Finally, for those concerned that a global error handler will make it difficult to catch errors that need special treatment, I’ll argue that if you have control over your GraphQL schema, you might be better off codifying that error information in the schema, or improving your user interface to minimise the chance of the error happening in the first place.

Getting Started

Let’s start with a simple Apollo Client setup, without any error handling:

import ReactDOM from "react-dom";
import App from "./App";
import {
  ApolloClient,
  ApolloLink,
  ApolloProvider,
  HttpLink,
  InMemoryCache
} from "@apollo/client";

const apolloClient = new ApolloClient({
  cache: new InMemoryCache(),
  link: ApolloLink.from([
    new HttpLink({ uri: "http://localhost:4000" })
  ]),
 });
  
ReactDOM.render(
  <ApolloProvider client={apolloClient}>
    <App />
  </ApolloProvider>,
  document.getElementById("root")
);

This wraps our entire App component in an ApolloProvider, meaning that anything in the App component hierarchy can use the Apollo React hooks.

Every time you execute one of these hooks, you can choose to handle (or ignore) any errors as you see fit. If you don’t want to do this everywhere and would instead prefer to always log errors to the browser console, you would set up an Apollo Client Error Link to do it:

...
import { onError } from "@apollo/client/link/error";

const apolloClient = new ApolloClient({
  cache: new InMemoryCache(),
  link: ApolloLink.from([
    new HttpLink({ uri: "http://localhost:4000" }),
    onError(({ graphQLErrors, networkError }) => {
      if (networkError) {
        console.log(`[Network error]: ${networkError}`);
      }

      if (graphQLErrors) {
        graphQLErrors.forEach(({ message, locations, path }) =>
          console.log(
            `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
          )
        );
      }
    })
  ]),
 });
 ...

But this isn’t much use to the user, who may not even know what a browser console is, let alone when they should open it up to check if an error occurred. Instead, if a problem occurs in a hook and there’s no specific error handling being done at that call-site, the user will likely just be left starting at a blank section on the page, or an activity indicator that spins indefinitely.

However, our code isn’t currently structured in a way that lets the error link trigger our UI when an issue occurs, because the ApolloClient instance has been created before we even start rendering the UI. Fortunately, this doesn’t have to be the case.

A Naive Approach

Let’s start by defining a (really) simple component that can be used to display a (really) simple error message to the user when requested. In this case, the component will use the function-as-child pattern to pass a callback to its children. The children can then use this callback to trigger displaying the error message whenever they want. The code for the component will look like this:

import { useState } from "react"
...
function ErrorNotifier({ children }) {
  const [doShowError, setDoShowError] = useState(false);
  return (
    <>
      {doShowError ? (
        <>
          <h1 role="alert">Error!</h1>
          <button onClick={() => setDoShowError(false)}>Dismiss</button>
        </>
      ) : null}
      {children(() => setDoShowError(true))}
    </>
  );
}

We also include a “Dismiss” button that will hide the error message when clicked.

Note that in real-world usage an error notification would probably be much more user-friendly than this. For example, it could be a pop-up dialog, or a toast notification. We could also make the trigger function accessible to children via a React Context, rather than passing it into the child function. However, for the sake of keeping this post short, we’re going with the simplest thing that can possibly work.

To use this component, we first wrap it around the ApolloProvider:

ReactDOM.render(
  <ErrorNotifier>
    {(showError) => (
      <ApolloProvider client={apolloClient}>
        <App />
      </ApolloProvider>
    )}
  </ErrorNotifier>,
  document.getElementById("root")
);

Although this still isn’t really going to help much, because the showError callback isn’t being invoked anywhere yet. What we really want is for the error link that was passed into the ApolloClient instance to be able to invoke showError. To achieve this, we’re going to do something a little unconventional and defer the creation of the instance until after showError is available:

...
ReactDOM.render(
  <ErrorNotifier>
    {(showError) => (
      <ApolloProvider client={new ApolloClient({
        cache: new InMemoryCache(),
        link: ApolloLink.from([
          onError(({ graphQLErrors, networkError }) => {
            if (networkError) {
              ...
              showError();
            }

            if (graphQLErrors) {
              ...
              showError();
            }
          }),
          new HttpLink({ uri: "http://localhost:3000" }),
        ]),
      })}>
        <App />
      </ApolloProvider>
    )}
  </ErrorNotifier>,
  document.getElementById("root")
);

So now, whenever Apollo Client detects an error, it will both log it to the console and notify the user that a problem occurred. Problem solved, right?

Not Quite

Anybody who has worked much with Apollo Client will probably be freaking out at this point, because what we’ve done here means that a new instance of ApolloClient will be created every time ErrorNotifier renders, which will be every time an error occurs. This in-turn means that the in-memory cache that was kept by the previous ApolloClient instance will be thrown away, and a new cache created. This is not a good thing.

Fortunately, we can use some React-fu to stop this from happening. Let’s start by moving the logic that creates the ApolloClient instance and renders the ApolloProvider into its own component, called MyApolloProvider. We’ll then have MyApolloProvider use the useMemo hook to memoize the ApolloClient instance:

import { useMemo } from "react"
...
function MyApolloProvider({ showError }) {
  const apolloClient = useMemo(
    () => new ApolloClient({
      ... // Same configuration as before
    }), 
    [showError]
  )

  return (
    <ApolloProvider client={apolloClient}>
      {children}
    </ApolloProvider>
  );
}
...

Note how we pass the showError function to the component as a prop, and make this prop value a dependency of the useMemo call, This is necessary because if the showError function changes, then strictly speaking we need to create a new ApolloClient instance that uses the new version of the function.

Now we can switch our code to use MyApolloProvider:

...
ReactDOM.render(
  <ErrorNotifier>
    {(showError) => (
      <MyApolloProvider showError={showError}>
        <App />
      </MyApolloProvider>
    )}
  </ErrorNotifier>,
  document.getElementById("root")
);

But we’re not out of the woods yet. The ErrorNotifier component feeds a new instance of the showError function to its children every time it renders. Because this instance is passed into MyApolloProvider, whenever an error occurs the useMemo call will still discard the ApolloClient instance it has and create a new one.

The good news is that the showError function doesn’t have to change every time an error occurs. This means we can use a useCallback hook inside the ErrorNotifier component to ensure that only one instance of the showError function is used:

function ErrorNotifier({ children }) {
  const [doShowError, setDoShowError] = useState(false);
  const showError = useCallback(() => setDoShowError(true), []);

  return (
    <>
      {doShowError ? (
        <>
          <h1 role="alert">Error!</h1>
          <button onClick={() => setDoShowError(false)}>Dismiss</button>
        </>
      ) : null}
      {children(showError)}
    </>
  );
}

Now if an error occurs in our app, the user will be notified, but our existing ApolloClient instance will be retained. Only re-mounting the whole ErrorNotifier would trigger recreation, and that’s not going to happen very often (if at all) because the notifier is so high up in the component tree.

The useMemo docs state that “you may rely on useMemo as a performance optimization, not as a semantic guarantee”. The same can be said for useCallback. In this case I consider the performance optimization to be that we are retaining a single ApolloClient instance for as long as possible. Without this optimization the app will continue to work, but all data will be re-loaded from the backend after an error occurs.

Working with different Apollo Client GraphQL error policies

By default, if Apollo Client finds any errors in a response it receives from a GraphQL server, it will disregard the data in that response. The graphQLErrors property that it provides to the onError error link will be populated with the errors, but the data will be undefined.

This is not always the desired behaviour. Sometimes partial data in a response is of interest to the client, even if some errors have also been reported.

To deal with this scenario, Apollo Client supports different error policies. Error policies can be set both globally or for individual operations. To set them globally, you can provide a defaultOptions object to the ApolloClient constructor. So if we wanted to retain partial data even if an error occurs for any operation, we’d set our ApolloClient instance up like this:

...
function MyApolloProvider({ showError }) {
  const apolloClient = useMemo(
    () => {
      const baseOptions = { errorPolicy: "all" }

      return new ApolloClient({
        defaultOptions: {
          watchQuery: baseOptions,
          query: baseOptions,
          mutate: baseOptions,
        },
        ... // Same configuration as before
      }), 
    }, 
    [showError]
  )
  ...
}      


Note that versions of Apollo Client prior to 3.1.0 either didn’t support defaultOptions at all, or had bugs in the way it was implemented. If you want to use defaultOptions, I recommend using Apollo Client 3.1.0 or later.

However, our work here is not yet done. If we want our client to be able to deal with partial data, we should probably stop displaying an error message every time there are errors in the response we get from the GraphQL server. But that begs the question: under what circumstance should we display an error message?

I have found a good criteria is to only notify the user when absolutely no data has been received. Generally this happens if there was a GraphQL syntax error, validation error, or an error that has propagated up to all of the root fields in the request. In any of these cases, the data property in the GraphQL response will either be null or missing completely. So we’ll add this as an extra check to do before we pop up an error message:

...
function MyApolloProvider({ showError }) {
  const apolloClient = useMemo(
    () => 
      new ApolloClient({
        defaultOptions: {
          query: {
            errorPolicy: "all",
          },
          mutate: {
            errorPolicy: "all",
          },
        },
        link: ApolloLink.from([
          onError(({ graphQLErrors, networkError, response }) => {
            if (networkError) {
              console.log(`[Network error]: ${networkError}`);
              showError();
            }

            if (graphQLErrors) {
              graphQLErrors.forEach(({ message, locations, path }) =>
                console.log(
                  `[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`
                )
              );
              // Only notify the user if absolutely no data came back
              if (!response || !response.data) {
                showError();
              }
            }
          })
        ...
      }), 
    [showError]
  )
  ...
}      

Now we’ll only notify the user of a problem if there were GraphQL errors and no data came back in the response. Note that users will still always be notified of networking errors, as they will never include any data.

What about GraphQL errors that I want to handle myself?

Sometimes it may be that you want to be able to detect and handle particular GraphQL errors yourself, rather than letting a default error handler catch them and show the user a generic message. For example, you might want to pick up certain server-side validation errors and report them to the user.

If this is the case, the solution we’ve proposed here might not be for you. However, if you control the GraphQL schema used by your client, we don’t see many good reasons for detecting and handling specific GraphQL errors in the client anyway, and prefer to just put a global error handler in-place. There are a couple of reasons for this.

Detecting specific GraphQL errors is hard to do right

Firstly, examining the errors property in a GraphQL response is a process that is itself quite prone to errors. The GraphQL spec specifies a structure for this field, including a property called extensions which contain a map of arbitrary additional information about each error. However, the spec leaves it to the implementor to decide what keys and values extensions should have. Because the structure can’t be formally specified, you can’t leverage code generators to generate types and let you know if either the client or server have made a bad assumption about how it is structured. This means that if either of them gets it wrong, then you won’t find out until runtime.

Instead, we’ve found that if you really need to be able to handle a specific error scenario on the client, it’s much safer to use the approach advocated by Sasha Solomon in her blog post “200 OK! Error Handling in GraphQL”. This technique leverages GraphQL union types to encapsulate information about particular error cases in the GraphQL schema. Because this information is encoded in the schema, it is well-typed. This means it works with code generation tools, which can warn you of potential problems prior to runtime.

It (arguably) signifies a fault in the user interface

The second reason we avoid detecting specific GraphQL server errors in the client is that letting the user get to a point where a server-side error occurs arguably signals a fault in the user interface, not the user. We can’t think of many server-side validation errors that could not be headed-off in advance by a client, be they for simple validations like maximum numeric values or string lengths, or more complex ones like uniqueness checks.

This doesn’t mean you shouldn’t perform the check on the server at all, as the server should always be the final line of defence for validating inputs. However, it doesn’t have to be the only line of defence, especially if the result would be a sub-optimal user experience.

Instead, consider the UI to be the first line of defence, and the server to be the second. If a check slips through the first line and is only picked up by the second, that’s better than it not being picked up at all, but you probably should look at fixing the hole by improving the user interface before the problem happens again.

If you adopt this philosophy, then a global error handler is useful because it can flag to you that something has gotten through to your server that probably shouldn’t have. If you’re concerned about this creating too much noise for your end-users, you can switch it off in production, but leave it enabled during development and testing.

Let’s wrap this up

In this post I’ve introduced a technique for notifying the user when any network or GraphQL error is encountered by Apollo Client. I’ve also demonstrated how this technique can be used with responses that contain partial data. Finally, I’ve made the case that, if you have control of your schema, GraphQL errors are not a particularly good mechanism for communicating specific error information directly to the user. Instead, if you want to provide the user with more than just a generic message for a particular error scenario, you should look at codifying that error information in your GraphQL schema, or improving your user interface to reduce the chance of the error happening in the first place. A global error handler can then act as a last-resort defence for flagging error scenarios that you hadn’t thought about in advance.

ben.teese@shinesolutions.com

I'm a Senior Consultant at Shine Solutions.

No Comments

Leave a Reply