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.
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.
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:
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.
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
touseMutation
hook, and get a functiondoRegister
and the mutation states:loading
,error
,data
; - Add a function
registerSubmit
to deal with the form submit, in the function we pass theuserInfo
to thedoRegister
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.
If we change the first argument to another string, then the message shows on the UI will change too.
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:
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:
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'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:
We could also see the message 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.
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.