Chen Yang

Error Handling with Apollo and Prisma

I think error handling is a very tricky part of development. When I started as a self-taught Node.js full-stack developer, I know use throw new Error would throw out the errors in the terminal, but I don't know how to let my frontend "know" these errors and show them in the UI for users. I really struggled with this for a while.

In this article, I'll showcase how to handle errors with Apollo Server & Prisma, then show the error messages in the frontend with Apollo Client & React. Thus, the users could know what is happening thereby adjust their inputs.

I'll make a register form as demonstration.

Register form with error messages
Register form with error messages

1. Setup the Project

Please download the starter repo, it contains the basic server structures and frontend UI.

The main part we concern is the register mutation in the resolvers.js file, take a look at it:

// server/graphql/resolvers.js

// ...

Mutation: {
  register: (_, { email, username, password, passconf }, { prisma }) => {
    let errors = {};

    try {
      if (email.trim() === "") errors.email = "Email must not be empty.";
      if (username.trim() === "") errors.username = "Username must not be empty.";
      if (password === "") errors.password = "Password must not be empty.";
      if (passconf !== password) errors.passconf = "Passwords must match.";

      if (Object.keys(errors).length > 0) {
        // now `errors` will throw to the `catch` block
        throw errors;
      }

      const user = prisma.user.create({
        data: {
          email,
          username,
          password
        }
      });

      return user;
    } catch (err) {
      console.log(err);
    }
  },
},

// ...

If there're some input errors, we put them in the errors object and throw them to the catch block. Let's see what would be like when those errors happen.

Start the server and run the register mutation, we can see the error messages on the terminal, but nothing on the GraphQL API.

Errors show in terminal
Errors show in terminal
No error messages show in playground
No error messages show in playground

2. Add Error Handing in Apollo Server

Let's add some code then.

// server/graphql/resolvers.js

const { UserInputError } = require('apollo-server');

const resolvers = {
  // ...
  Mutation: {
    register: () => {
      let errors = {};
      try {
        // ...
      } catch (err) {
        console.log(err);
        throw new UserInputError('Bad Request', { errors: err });
      }
    },
  },
};

// ...

We use the Apollo Server's UserInputError error handler to throw the errors to GraphQL API. Now, we can see the messages in the playground:

Error messages in Playground
Error messages in Playground

The UserInputError method accepts two arguments, the first one usually is a string that could describe the error(1️⃣), the 2nd could be the actual messages like what we did, these messages show in the errors[0].extensions (2️⃣) of the response. And the 2nd one is optional.

Code of UserInputError
Code of UserInputError

Before dive into how Prisma could help us handle the errors, let's wire up with frontend to see how to deal the messages with UI.

3. Show Error Messages in React App with Apollo Client

In the frontend, we use the Semantic UI React to make the demo app more beautiful which is already in the starter repo.

To wire up with the server, let's install Apollo Client.

1) Add Apollo Client to Frontend

Install Apollo Client:

yarn add @apollo/client graphql

# or

npm install @apollo/client graphql

Add a new file ./client/src/ApolloProvider.js, and import it to ./client/src/index.js:

// client/src/ApolloProvider.js

import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client';

const client = new ApolloClient({
  uri: 'http://localhost:4000', // this points to our server url
  cache: new InMemoryCache(),
});

const Provider = ({ children }) => (
  <ApolloProvider client={client}>{children}</ApolloProvider>
);

export default Provider;
// client/src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import ApolloProvider from './ApolloProvider';
import App from './App';
import 'semantic-ui-css/semantic.min.css';

ReactDOM.render(
  <ApolloProvider>
    <App />
  </ApolloProvider>,
  document.getElementById('root')
);

2) Wire Up with the Server

Wire up the register resolver with register page.

In the ./client/src/components/RegisterForm.js, we use the useMutation hook from Apollo Client to mutate the data with the server. Code below:

// client/src/components/RegisterForm.js

import { useState } from 'react';
import { gql, useMutation } from '@apollo/client';
import { Button, Form, Message } from 'semantic-ui-react';

const REGISTER = gql`
  mutation Register(
    $email: String!
    $username: String!
    $password: String!
    $passconf: String!
  ) {
    register(
      email: $email
      username: $username
      password: $password
      passconf: $passconf
    ) {
      id
      username
      email
    }
  }
`;

const Register = () => {
  const [userInfo, setUserInfo] = useState({
    username: '',
    email: '',
    password: '',
    passconf: '',
  });

  const handleChange = (e) => {
    setUserInfo({
      ...userInfo,
      [e.target.name]: e.target.value,
    });
  };

  const [doRegister, { loading, error, data }] = useMutation(REGISTER);

  const registerSubmit = (e) => {
    e.preventDefault();
    doRegister({ variables: { ...userInfo } });
    console.log(userInfo);
  };

  console.log('data:', data);
  console.log('error:', error);

  return (
    <>
      {error && <Message negative>{error.message}</Message>}
      <Form onSubmit={registerSubmit}>{/* omit the form content */}</Form>
    </>
  );
};

export default Register;

What have we done in the code?

  • Define a GraphQL string with gql;
  • Pass the REGISTER to useMutation hook, and get a function doRegister and the mutation states: loading, error, data;
  • Add a function registerSubmit to deal with the form submit, in the function we pass the userInfo to the doRegister to execute the mutation;
  • Add a Message component to show the error message, if there is any error.

At this point, when leaving all the four fields empty and click the "Register" button, we'll get the error message, the message is what we write in the UserInputError in the register resolver, the FIRST argument.

Error message: Bad Request
Error message: Bad Request
The first argument of UserInputError
The first argument of UserInputError

If we change the first argument to another string, then the message shows on the UI will change too.

Error message: Bad User Input
Error message: Bad User Input
"Bad User Input" in UserInputError
"Bad User Input" in UserInputError

Then, how could we get the error messages we defined in the errors object? We use the options method onError of the useMutaiton hook:

// client/src/components/RegisterForm.js

// ...

const Register = () => {
  // ...

  const [doRegister, { loading, error, data }] = useMutation(REGISTER, {
    onError: (err) => {
      console.log('onError:', err.graphQLErrors[0].extensions.errors);
    },
  });

  // ...
};

// ...

In the browser console, we get these:

Error messages in browser console
Error messages in browser console

3) Show Error Message in the UI

Let's use the semantic UI field error state to show the messages in our UI.

// client/src/components/RegisterForm.js

// ...

const Register = () => {
  // ...

  // add a new state to store the error messages
  const [errors, setErrors] = useState({
    username: '',
    email: '',
    password: '',
    passconf: '',
  });

  // ...

  return (
    <>
      {error && error.message !== 'Bad User Input' && (
        <Message negative>{error.message}</Message>
      )}

      <Form onSubmit={registerSubmit}>
        {/* change the JSX to `Form.Input` */}
        <Form.Input
          // conditional show the label content
          label={errors.username ? errors.username : 'Username:'}
          type="text"
          name="username"
          value={userInfo.username}
          onChange={handleChange}
          placeholder="Please input your username"
          // if its status is error
          error={errors.username ? true : false}
        />
        <Form.Input
          label={errors.email ? errors.email : 'E-mail:'}
          type="email"
          name="email"
          value={userInfo.email}
          onChange={handleChange}
          placeholder="Please input your email"
          error={errors.email ? true : false}
        />
        <Form.Input
          label={errors.password ? errors.password : 'Password:'}
          type="password"
          name="password"
          value={userInfo.password}
          onChange={handleChange}
          placeholder="Please input your password"
          error={errors.password ? true : false}
        />
        <Form.Input
          label={errors.passconf ? errors.passconf : 'Confirm Password:'}
          type="password"
          name="passconf"
          value={userInfo.passconf}
          onChange={handleChange}
          placeholder="Please confirm your password"
          error={errors.passconf ? true : false}
        />
        <Button primary type="submit" loading={loading}>
          Register
        </Button>
      </Form>
    </>
  );
};

// ...

Now, we click the "Register" button when the fields are empty, we get these:

Register form with error messages
Register form with error messages

4) Complete the Register Page

Continue to add the onCompleted option to mutation hook to get the successfully returned data:

// client/src/components/RegisterForm.js

// ...

const RegisterForm = () => {
  // ...
  // add a new state to store the register result
  const [registerResult, setRegisterResult] = useState({
    username: '',
    email: '',
  });

  const [doRegister, { loading, error }] = useMutation(REGISTER, {
    onError: (err) => {
      // ...
    },
    onCompleted: (data) => {
      // when the mutation complete, set the response to `registerState` state
      setRegisterResult(data.register);
    },
  });

  return (
    <>
      {/* ... */}
      <Form onSubmit={registerSubmit}>{/* ... */}</Form>

      {/* when we have the register result, show this: */}
      {registerResult.username && (
        <Message positive>
          <Message.Header>Register Successfully!</Message.Header>
          <p>
            You&#39;re <strong>{registerResult.username}</strong> with{' '}
            <em>{registerResult.email}</em>.
          </p>
          <p>
            Back to <Link to="/">Home page</Link>
          </p>
        </Message>
      )}
    </>
  );
};

// ...

5) Summary

Sum up our first part of error handling. Using the guard clause concept, we put error messages to an errors object and then pass it to the UserInputError of Apollo Server.

// server/graphql/resolvers.js

// ...
let errors = {};
try {
  if (email.trim() === '') errors.email = 'Email must not be empty.';
  if (username.trim() === '') errors.username = 'Username must not be empty.';
  if (password === '') errors.password = 'Password must not be empty.';
  if (passconf !== password) errors.passconf = 'Passwords must match.';

  if (Object.keys(errors).length > 0) {
    // now `errors` will throw to the `catch` block
    throw errors;
  }

  // ...
} catch (err) {
  throw new UserInputError('Bad User Input', { errors: err });
}

// ...

In the front end, we use the useMutation hook from Apollo Client to store error messages to a local React state and then show the messages on UI with error status.

// client/src/components/RegisterForm.js

// ...

const Register = () => {
  // ...
  const [errors, setErrors] = useState({
    username: '',
    email: '',
    password: '',
    passconf: '',
  });

  // ...

  const [doRegister, { loading, error }] = useMutation(REGISTER, {
    onError: (err) => {
      // when we get the error messages, we set it to the `errors` state
      setErrors(err.graphQLErrors[0].extensions.errors);
    },
    onCompleted: (data) => {
      // ...
    },
  });

  // ...

  return (
    <>
      {/* ... */}
      <Form onSubmit={registerSubmit}>
        <Form.Input
          label={errors.username ? errors.username : 'Username:'}
          // ...
          error={errors.username ? true : false}
        />
        <Form.Input
          label={errors.email ? errors.email : 'E-mail:'}
          // ...
          error={errors.email ? true : false}
        />
        <Form.Input
          label={errors.password ? errors.password : 'Password:'}
          // ...
          error={errors.password ? true : false}
        />
        <Form.Input
          label={errors.passconf ? errors.passconf : 'Confirm Password:'}
          // ...
          error={errors.passconf ? true : false}
        />
        {/* ... */}
      </Form>

      {/* ... */}
    </>
  );
};

export default Register;

4. Error Handling with Prisma

You could find the entire code of the above part in here.

Assume we've already had some users, let's use the Prisma exceptions to deal with the unique constraint violation.

I define the email and username fields as unique in the User model.

// server/prisma/schema.prisma

model User {
  id       Int    @id @default(autoincrement())
  email    String @unique
  username String @unique
  password String
}

When we use an existed username to register, we'll get some error messages from Prisma's exception:

Unique violation error messages with Prisma
Unique violation error messages with Prisma

We could also see the message in terminal:

Unique violation error in terminal
Unique violation error in terminal

According to Prisma's documentation, code P2002 specifies the unique constraint violation. So we can use it as a condition. Refactor the catch block like this:

// server/graphq/resolvers.js

// ...

} catch (err) {
    if (err.code === "P2002") {
      const field = err.meta.target[0];
      errors[field] = `${field} is already taken.`;
    }
    throw new UserInputError("Bad User Input", { errors });
  }

// ..

Now we get the error messages just like before, could use it directly in our frontend without any code changes.

Error messages with Prisma in Playground
Error messages with Prisma in Playground
Error message from Prisma shows on frontend
Error message from Prisma shows on frontend

5. Conclusion

With the very helpful and various error references in Prisma, we put any kind of error messages in an errors object and pass it to the Apollo Server's built-in method UserInputError. In this case, the error messages will show in GraphQL API.

In the frontend, with the help of Apollo Client, we could show those messages on our UI.

You could find the final codebase in this repo.