DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Abi Noda
Abi Noda

Posted on • Updated on

Custom error pages in React with GraphQL and Error Boundaries

If you like this article please support me by checking out Pull Reminders, a Slack bot that sends your team automatic reminders for GitHub pull requests.

One challenge I recently ran into while working with GraphQL and React was how to handle errors. As developers, we’ve likely implemented default 500, 404, and 403 pages in server-rendered applications before, but figuring out how to do this with React and GraphQL is tricky.

In this post, I’ll talk about how our team approached this problem, the final solution we implemented, and interesting lessons from the GraphQL spec.

Background

The project I was working on was a fairly typical CRUD app built in React using GraphQL, Apollo Client, and Express GraphQL. We wanted to handle certain types of errorsβ€Šβ€”β€Šfor example, the server being downβ€Šβ€”β€Šby displaying a standard error page to the user.

Our initial challenge was figuring out the best way to communicate errors to the client. GraphQL doesn’t use HTTP status codes like 500, 400, and 403. Instead, responses contain an errors array with a list of things that went wrong (read more about errors in the GraphQL spec).

For example, here’s what our GraphQL response looked like when something broke on the server:

{
  "errors": [
    {
      "message": "TypeError: Cannot read property 'name' of undefined",
      "locations": [
        {
          "line": 2,
          "column": 2
        }
      ],
      "path": [
        "program"
      ]
    }
  ],
  "data": {
    "program": null
  }
}
Enter fullscreen mode Exit fullscreen mode

Since GraphQL error responses return HTTP status code 200, the only way to identify the kind of error was to inspect the errors array. This seemed like a poor approach because the error message was the message from the exception thrown on the server. The GraphQL spec states that the value of message is intended for developers, but it does not specify whether the value should be a human-readable message or something designed to be programmatically handled:

Every error must contain an entry with the key message with a string description of the error intended for the developer as a guide to understand and correct the error.

Adding Error Codes to GraphQL Responses

To solve for this, we added standardized error codes to our error objects, which could be used by clients to programmatically identify errors. This was inspired by how Stripe's REST API returns string error codes in addition to human-readable messages.

We decided on three error codes to start: authentication_error, resource_not_found, and server_error.

To add these to our GraphQL responses, we passed our own formatError function to graphql-express that maps exceptions thrown on the server to standard codes which get added to the response. The GraphQL spec generally discourages adding properties to error objects, but does allow for it by nesting those entries in an extensions object.

const formatError = (error) => {
  const { constructor } = error.originalError;

  let code;

  switch (constructor) {
    case AuthorizationError:
      code = 'authorization_error';
    case ResourceNotFound:
      code = 'resource_not_found';
    default:
      code = 'server_error';
  }

  return {
    extensions: {
      code
    },
    ...error
  };
};

app.use('/graphql', (req, res) => graphqlHTTP({
  schema,
  graphiql: config.graphiql,
  context: { req, res },
  formatError
})(req, res));
Enter fullscreen mode Exit fullscreen mode

Our GraphQL response errors were then easy to classify:

{
  "errors": [
    {
      "message": "TypeError: Cannot read property 'name' of undefined",
      "locations": [
        {
          "line": 2,
          "column": 2
        }
      ],
      "path": [
        "program"
      ],
      "extensions": {
        "code": "server_error"
      }
    }
  ],
  "data": {
    "program": null
  }
}
Enter fullscreen mode Exit fullscreen mode

While we developed our own way of adding codes to responses generated by express-graphql, apollo-server appears to offer similar built-in behavior.

Rendering error pages with React Error Boundaries

Once we figured out a good way of handling errors in our server, we turned our attention to the client.

By default, we wanted our app to display a global error page (for example, a page with the message β€œoops something went wrong”) whenever we encountered a server_error, authorization_error, or authorization_not_found. However, we also wanted the flexibility to be able to handle an error in a specific component if we wanted to.

For example, if a user was typing something into a search bar and something went wrong, we wanted to display an error message in-context, rather than flash over to an error page.

To achieve this, we first created a component called GraphqlErrorHandler that would sit between apollo-client’s Query and Mutation components and their children to be rendered out. This component checked for error codes in the response threw an exception if it identified a code we cared about.

import React from 'react';
import {
  ServerError,
  AuthorizationError,
  ResourceNotFound
} from '../errors';

const checkFor = (code, errors) => errors && errors.find( e => e.extensions.code === code);

const checkError = ({ networkError, graphQLErrors }) => {
  // networkError is defined when the response is not a valid GraphQL response, e.g. the server is completely down
  if ( networkError ) {
    throw new ServerError();
  }

  if (checkFor('server_error', graphQLErrors)) {
    throw new ServerError();
  }

  if (checkFor('authorization_error', graphQLErrors)) {
    throw new AuthorizationError();
  }

  if (checkFor('resource_not_found', graphQLErrors)) {
    throw new ResourceNotFound();
  }
};

const GraphqlErrorHandler = ({ error, children }) => {
  if (error) checkError(error);
  return children;
};

export default GraphqlErrorHandler;
Enter fullscreen mode Exit fullscreen mode

To use the GraphqlErrorHandler, we wrapped apollo-client’s Query and Mutation components:

import React from 'react';
import Query from 'Components/graphql/Query';
import GET_PROGRAM from './queries/getProgram';
import ViewProgram from './ViewProgram';

const ViewProgramContainer = (props) => {
  const { programCode } = props.match.params;

  return (
    <Query query={GET_PROGRAM} variables={{ programCode }}>
      {({ loading, data, }) => (
        <ViewProgram program={data.program} loading={loading} />
      )}
    </Query>
  );
};

export default ViewProgramContainer;
Enter fullscreen mode Exit fullscreen mode

Now that our React app was throwing exceptions when the server returned errors, we wanted to handle these exceptions and map them to the appropriate behavior.

Remember from earlier that our goal was to default to displaying global error pages (for example, a page with the message β€œoops something went wrong”), but still have the flexibility to handle an error locally within any component if we desired.

React error boundaries provide a fantastic way of doing this. Error boundaries are React components that can catch JavaScript errors anywhere in their child component tree so you can handle them with custom behavior.

We created an error boundary called GraphqlErrorBoundary that would catch any server-related exceptions and display the appropriate error page:

import React from 'react';
import ServerErrorPage from 'Components/errors/ServerError';
import NotFoundPageErrorPage from 'Components/errors/NotFound';
import UnauthorizedErrorPage from 'Components/errors/Unauthorized';
import {
  ServerError,
  AbsenceError,
  AuthorizationError,
  ResourceNotFound
} from '../errors';

class GraphqlErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      error: null
    };
  }

  componentDidCatch(error) {
    if ( error.name === AuthorizationError.name ) {
      this.setState({ error: AuthorizationError.name });
    } else if ( error.name === ServerError.name ) {
      this.setState({ error: ServerError.name });
    } else if ( error.name === ResourceNotFound.name ) {
      this.setState({ error: ResourceNotFound.name });
    } else {
      this.setState({ error: ServerError.name });
    }
  }

  render() {
    if (this.state.error === ServerError.name ) {
      return <ServerErrorPage />
    } else if (this.state.error === AuthorizationError.name) {
      return <UnauthorizedErrorPage />
    } else if (this.state.error === ResourceNotFound.name) {
      return <NotFoundErrorPage />
    }
    return this.props.children;
  }
}

export default GraphqlErrorBoundary;
Enter fullscreen mode Exit fullscreen mode

We then wrapped our app’s components with this error boundary:

const App = () => {
  return (
    <div className='appContainer'>
      <Header />
      <GraphqlErrorBoundary>
        <Routes />
      </GraphqlErrorBoundary>
      <Footer />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

If we wanted to handle errors within a specific component instead of rendering an error page, we could turn that component into an error boundary. For example, here’s what it’d look if we wanted custom error handling behavior in our component from earlier:

import React from 'react';
import Query from 'Components/graphql/Query';
import GET_PROGRAM from './queries/getProgram';
import ViewProgram from './ViewProgram';

class ViewProgramContainer extends React.Component {
  componentDidCatch(error) {
    if (error.name === ServerError.name) {
      // do something
    }
  }

  render() {
    const { programCode } = this.props.match.params;

    return (
      <Query query={GET_PROGRAM} variables={{ programCode }}>
        {({ loading, data, }) => (
          <ViewProgram program={data.program} loading={loading} />
        )}
      </Query>
    );
  }
}

export default ViewProgramContainer;
Enter fullscreen mode Exit fullscreen mode

Wrap up

GraphQL is still relatively new, and error handling is a common challenge that developers seem to be running into. By using standardized error codes in our GraphQL responses, we can communicate errors to clients in a useful and intuitive way. In our React apps, error boundaries provide a great way to standardize our app’s error handling behavior while still having flexibility when we need it.

Top comments (0)

πŸ€” Did you know?

✍️ Writing your own article is easy (we even support markdown)