Error handling with Apollo Client
February 08, 2020
In continuation of the previous post dedicated to server-side error handling, in this post let’s focus on error handling in a react application with Apollo Client. In such an application we can experience errors related to GraphQL response as well as other, non-GraphQL-related errors in the UI or internal logic.
GraphQL-related errors can be accessed in an application through ApolloError object containing fields:
- networkError - when no response was obtained
- graphQLErrors array - contains errors returned by GraphQL server
- message - an error message, by default concatenated from error messages of networkError and graphQLErrors
- extraInfo - used internally by Apollo Client
Apollo Client’s capabilities that we can use for error handling are described in Apollo’s documentation and blog. Let’s shortly summarize what capabilities we have at hand. Since every GraphQL response can contain both data and errors, for every query or mutation individual errorPolicy can be set. This setting allows to ignore data, ignore errors or retain both in the response. When errors are not ignored they can be handled either globally by providing a global handler or locally on component level using one of two options:
- error object returned from useQuery/useMutation
- an error handler provided to onError property of useQuery’s/useMutation’s configuration object
Which option to choose depends on what result we want to achieve:
- When we want to change the internal state of a component then we have to use onError callback. Otherwise, when we try to change the state outside of a handler we’ll get the following error from React: “Error: Too many re-renders. React limits the number of renders to prevent an infinite loop.”.
- When we want to display a custom component we should use error object, since there is no way to return a component from onError handler.
In short, for imperative code we should use onError handler and for declarative code we should use error object. In the next section let’s cover how GraphQL errors can be handled using onError handlers.
1. Handling GraphQL errors imperatively
Errors can be handled either globally or locally on the component level. First let’s cover global handling. A global handler can be set either with use of apollo-link-error or with Apollo Boost by directly using onError property:
export const client = new ApolloClient({
onError: ({ operation, response, graphQLErrors, networkError }) => {
// here errors can be handled globally
},
});
How the global handler is used depends on the project’s requirements and may include changing global state, remote logging, refreshing authentication tokens after receiving an error with UNAUTHENTICATED code etc.
An error can be also handled on the component level via onError property of useQuery’s/useMutation’s configuration object. Continuing example from the previous post let’s see how error handling could look like in UpdateUserEmail component. First let’s build a custom hook useUpdateUserEmailMutation. For type safety - like on the server side - we also use types generated by graphql code generator :
import { useMutation, MutationHookOptions } from "@apollo/react-hooks";
import {
UpdateUserEmailMutation,
UpdateUserEmailMutationVariables,
} from "graphql/generated";
import { UPDATE_USER_EMAIL } from "graphql/operations/user";
export function useUpdateUserEmailMutation(
options?: MutationHookOptions<
UpdateUserEmailMutation,
UpdateUserEmailMutationVariables
>,
) {
return useMutation<UpdateUserEmailMutation, UpdateUserEmailMutationVariables>(
UPDATE_USER_EMAIL,
options,
);
}
Next we import the hook into UpdateUserEmail component and use it:
const [
updateUserEmail,
{ data, loading, error },
] = useUpdateUserEmailMutation({
onError: handleErrors,
});
Now we have to create handleErrors function, but first let’s recall what kind of errors we have to cover. updateUserEmailResolver could send us UserInputError with properties object containing operation property set to UPDATE_USER_EMAIL and codes property set to EMAIL_EXISTS or INVALID_PASSWORD. Our handleErrors function will have to cover these two cases, so let’s first write error handlers covering each of them. To not have to remember string literals representing error codes lets use UserInputErrorCode that we were using on the server side and excluded to a separate, common package:
export enum UserInputErrorCode {
EmailExists = "EMAIL_EXISTS",
InvalidCredentials = "INVALID_CREDENTIALS",
InvalidPassword = "INVALID_PASSWORD",
}
Using it we can build the error handlers, here is an example with react-hook-form used for form handling and react-intl used for i18n:
function handleEmailExistsError(): void {
const emailErrorMessage = intl.formatMessage({
id: "EmailTextFieldErrorId",
});
setError(
"emailTextFieldName",
UserInputErrorCode.EmailExists,
emailErrorMessage,
);
}
function handleInvalidPasswordError(): void {
const passwordErrorMessage = intl.formatMessage({
id: "CurrentPasswordTextFieldErrorId",
});
setError(
"currentPasswordTextFieldName",
UserInputErrorCode.InvalidPassword,
passwordErrorMessage,
);
}
Next we can use these handlers to build handleErrors function:
function handleErrors(error: ApolloError): void {
if (canHandleErrors(error, errorProperties)) {
const handlers = {
[UserInputErrorCode.EmailExists]: handleEmailExistsError,
[UserInputErrorCode.InvalidPassword]: handleInvalidPasswordError,
};
handleUserInputErrors(error, handlers);
}
}
Let’s analyze what the function does. It receives an error of type ApolloError, the error can contain networkError and/or graphQLErrors array. First, using canHandleErrors function, we check if the handler can properly handle all received errors. If the function returns true then handlers object is being built and passed to handleUserInputErrors function. canHandleErrors function receives error and errorProperties object that conveys information what error properties this UI can handle. To build errorProperties object we have to recall from the previous post what shape it has for UPDATE_USER_EMAIL operation:
export enum UserInputOperation {
AddUser = "ADD_USER",
SighIn = "SIGN_IN",
UpdateUserEmail = "UPDATE_USER_EMAIL",
}
interface ErrorProperties {
operation: UserInputOperation;
codes: Array<UserInputErrorCode>;
}
interface AddUserErrorProperties extends ErrorProperties {
operation: UserInputOperation.AddUser;
codes: Array<UserInputErrorCode.EmailExists>;
}
interface SignInErrorProperties extends ErrorProperties {
operation: UserInputOperation.SighIn;
codes: Array<UserInputErrorCode.InvalidCredentials>;
}
interface UpdateUserEmailErrorProperties extends ErrorProperties {
operation: UserInputOperation.UpdateUserEmail;
codes: Array<
UserInputErrorCode.EmailExists | UserInputErrorCode.InvalidPassword
>;
}
export type UserInputErrorProperties =
| AddUserErrorProperties
| SignInErrorProperties
| UpdateUserEmailErrorProperties;
The above code was also excluded to the common project so we can use UserInputErrorProperties discriminated union along with string enums UserInputOperation and UserInputErrorCode to build errorProperties object:
// This UI can gracefully handle UserInputErrors with the following properties:
const errorProperties: UserInputErrorProperties = {
operation: UserInputOperation.UpdateUserEmail,
codes: [UserInputErrorCode.InvalidPassword, UserInputErrorCode.EmailExists],
};
Now we can pass the object to canHandleErrors function. This function contains logic common for all components so it can be excluded to a separate module along with all helper functions it uses:
function isUserInputError(graphQLError: GraphQLError): boolean {
const result = graphQLError.extensions?.code === "BAD_USER_INPUT";
return result;
}
function isOperationExpected(
graphQLError: GraphQLError,
expectedOperation: string,
): boolean {
const isExpected =
graphQLError.extensions?.exception?.operation === expectedOperation;
return isExpected;
}
function getErrorCodes(
graphQLError: GraphQLError,
): UserInputErrorCode[] | null {
const receivedErrorCodes = graphQLError?.extensions?.exception?.codes;
if (!isUserInputErrorCodeArray(receivedErrorCodes)) {
return null;
}
return receivedErrorCodes;
}
function areErrorCodesExpected(
graphQLError: GraphQLError,
expectedErrorCodes: UserInputErrorCode[],
): boolean {
const receivedErrorCodes = getErrorCodes(graphQLError);
if (receivedErrorCodes === null) {
return false;
}
const areAllCodesExpected = receivedErrorCodes.every(code => {
const isErrorCodeExpected = expectedErrorCodes.includes(code);
return isErrorCodeExpected;
});
return areAllCodesExpected;
}
function doesIncludeOnlyKnownErrors(
error: ApolloError,
knownErrorProperties: UserInputErrorProperties,
): boolean {
const areAllErrorsKnown = error.graphQLErrors.every(graphQLError => {
const isErrorTypeValid = isUserInputError(graphQLError);
const isOperationValid = isOperationExpected(
graphQLError,
knownErrorProperties.operation,
);
const areAllErrorCodesValid = areErrorCodesExpected(
graphQLError,
knownErrorProperties.codes,
);
const isErrorKnown =
isErrorTypeValid && isOperationValid && areAllErrorCodesValid;
return isErrorKnown;
});
return areAllErrorsKnown;
}
export function canHandleErrors(
error: ApolloError,
knownErrorProperties: UserInputErrorProperties,
): boolean {
if (error.graphQLErrors.length === 0) {
return false;
}
// check if all errors can be handled:
const canHandleAllErrors = doesIncludeOnlyKnownErrors(
error,
knownErrorProperties,
);
return canHandleAllErrors;
}
Not going into details, canHandleErrors and its helper functions check that all received errors are of type UserInputError (have extension.code: BAD_USER_INPUT), have operation correctly set (in our example it should be UPDATE_USER_EMAIL) and that all error codes can be handled by this component (in our example valid error codes are EMAIL_EXISTS and INVALID_PASSWORD). If canHandleErrors returns true then errors are handled in handleUserInputErrors function:
export function handleUserInputErrors(
error: ApolloError,
handlers: {
[key: string]: () => void;
},
): void {
error.graphQLErrors.forEach(graphQLError => {
const errorCodes = getErrorCodes(graphQLError);
errorCodes?.forEach(code => {
const handler = handlers[code];
if (handler !== undefined) {
handler();
}
});
// }
});
}
This function takes handlers object and invokes an appropriate handler for each received error code. For example for EMAIL_EXISTS handleEmailExistsError is invoked and assigns EMAIL_EXISTS to emailTextFieldName input. The internal state is changed and React updates UI with error message of EmailTextFieldErrorId id.
What does this setup give us? Each component has to include only those constructs that are specific to that component:
- errorProperties object conveying information what errors the component can handle
- error handlers for each error code, in our example handleEmailExistsError and handleInvalidPasswordError
- and handleErrors function provided to useQuery/useMutation hook
All other error handling logic is excluded to an utility module and can be reused in all components.
And by using UserInputErrorProperties on both client and server side we gain type safety across the system - if any change in error’s properties object is made on either side and the change is not synchronized everywhere where the error is handled then during compilation Typescript will show places where source code needs update.
What if a component cannot handle errors imperatively and canHandleErrors function returns false? These errors have to be handled declaratively, let’s see how we can do that in the next section.
2. Handling GraphQL errors declaratively
For displaying a custom UI component when an error occurs we can use error object returned by useQuery/useMutation hook. In our example:
const [
updateUserEmail,
{ data, loading, error },
] = useUpdateUserEmailMutation({
onError: handleErrors,
});
Before displaying a fallback UI we have to check if the error has not been handled by onError handler. We can use previously defined canHandleErrors function for that:
if (error && !canHandleErrors(error, errorProperties)) {
return <DefaultError error={error} />;
}
The error can contain various error codes, if we want to differentiate the fallback UI based on an error code then it can be done internally in DefaultError component. For example if we want to:
- redirect to SIGN_IN route in case of error code UNAUTHENTICATED
- display a custom error message for other error codes
then DefaultError component could look like this:
function doesIncludeErrorType(
error: ApolloError,
apolloErrorType: string,
): boolean {
const result = error.graphQLErrors.some(graphQLError => {
return graphQLError.extensions?.code === apolloErrorType;
});
return result;
}
function doesIncludeAuthenticationError(error: ApolloError): boolean {
const result = doesIncludeErrorType(error, "UNAUTHENTICATED");
return result;
}
function doesIncludeForbiddenError(error: ApolloError): boolean {
const result = doesIncludeErrorType(error, "FORBIDDEN");
return result;
}
function doesIncludeInternalServerError(error: ApolloError): boolean {
const result = doesIncludeErrorType(error, "INTERNAL_SERVER_ERROR");
return result;
}
export function DefaultError(props: { error: ApolloError }) {
if (props.error.networkError) {
return <NetworkError />;
} else if (doesIncludeAuthenticationError(props.error)) {
return <Redirect to={routePaths.SIGN_IN} />;
} else if (doesIncludeForbiddenError(props.error)) {
return <ForbiddenError />;
} else if (doesIncludeInternalServerError(props.error)) {
return <InternalServerError />;
} else {
return <UnknownError />;
}
}
With this setup to every component that might experience errors we have to import only DefaultError component.
In the last section let’s see how non-GraphQL errors can be handled.
3. Handling non-GraphQL errors
While not a main subject of this post, for completeness let’s shortly cover how to handle other, non-GraphQL-related errors. We can also divide them into two categories: errors thrown in the UI and errors thrown in imperative code.
For handling the first category of errors React provides error boundaries that allow to catch errors in their child components, log errors and display a fallback UI. To provide higher granularity error boundaries can be nested, in such a case an error will be caught by the error boundary that is the closest parent of the component that generated the error. An error boundary can look like this:
export class ErrorBoundary extends React.Component<{}, { hasError: boolean }> {
constructor(props: {}) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
// Update state so the next render will show the fallback UI:
return { hasError: true };
}
componentDidCatch(error: Error) {
logger.error(functionPath, `error: ${JSON.stringify(error)}`);
}
render() {
if (this.state.hasError) {
// Render fallback UI:
return <UnknownError />;
}
return this.props.children;
}
}
In case of errors in imperative code:
- for synchronous code we can handle them by simply surrounding places where they may occur with a regular try/catch block,
- for asynchronous code we can also use a try/catch block with async/await construct or we can register an error handling callback either using catch method or as the second argument to then method at the end of a promises’ chain. As a last resort, a global event handler can be added for unhandledrejection event in one of the following ways:
window.addEventListener("unhandledrejection", event => {
// handle event here
});
window.onunhandledrejection = (event: PromiseRejectionEvent) => {
// handle event here
};
Summary
Let’s summarize, as a result of implemented changes we have a client application that handles GraphQL and non-GraphQL errors, imperatively and declaratively, globally and on the component level. And although errors are not part of GraphQL schema, by sharing error types between the server and client sides the system gained type safety also in it’s error handling part.
Looking forward to your comments and questions in this reddit thread.