How errors can be handled with react-query

Introduction

The aim of this post is to outline the possibilities of handling errors in applications with react-query and present you different approaches to it as well as provide an example of usage.

Agenda

  1. React query
  2. Type guards
  3. Global handling with a query client
  4. Custom wrapper for mutations
  5. Providing an example of usage

React query

Let’s start by checking the documentation on website https://tanstack.com/query/latest/docs/react/overview where we should find a lot of useful information about features of this library. In our example only the mutations will be used for errors handling, because the query api will be rebuilt in next version of react-query. To find more information about it you should check out this post on Dominic blog https://tkdodo.eu/blog/breaking-react-querys-api-on-purpose who is one of maintainers this library.

Type guards

The purpose of a type guard is to check the type of an object, so let's create a type guard for the error model and functions for handling errors.

type ApiError = {
  type: string;
  title: string;
  status: number;
  detail: string;
  instance: string;
};

function isApiErrorResponse(res: any): res is ApiError {
  return (
    res &&
    "type" in res &&
    "title" in res &&
    "status" in res &&
    "detail" in res &&
    "instance" in res
  );
}

export const handleErrorMessage = (error: unknown) => {
  if (!axios.isAxiosError(error)) {
    return "Unknown error";
  }

  if (!error.response) {
    return error.message;
  }

  if (!isApiErrorResponse(error.response.data)) {
    return error.message;
  }

  return error.response.data.detail;
};

export const handleErrorCode = (error: unknown) => {
  if (!axios.isAxiosError(error)) {
    throw Error("Unknown error");
  }

  if (!error.response) {
    return error.status;
  }

  if (!isApiErrorResponse(error.response.data)) {
    return error.response.status;
  }

  return error.response.data.status;
};

In above code we should pay attention to the isApiErrorResponse function which is a type guard. It has a specific syntax according to which a type guard should be created:

  • it must be defined with function keyword
  • its parameter type must be any
  • its parameter type must be predicate (res is ApiError)
  • we must check if all the properties are included in the parameter

Global handling with a query client

Let's create global error handling with using the query client in the App.tsx component

const App = () => {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        mutationCache: new MutationCache({
          onError: (error, _variables, _context, mutation) => {
            if (mutation.options.onError) return;

            const errorMessage = handleErrorMessage(error);
            toast.error(errorMessage);
          },
        }),
      })
  );

  return (
    <QueryClientProvider client={queryClient}>
      // Other elements
    </QueryClientProvider>
  );
};

export default App;

In above code we should pay attention to the queryClient definition, because in the documentation the library authors recommended define the queryClient above the component to create only one instance of the queryClient. However, this definition of the queryClient give us the same effect that is recommended by the authors of the react-query library. Another interesting elements is using of the mutationCache where we can define the global error handling, but first condition give us option to handle error differently in our particular mutation.

Upgrade your tech game with us

Custom wrapper for mutations

The global error handling with using mutation cache have the dominant flaw which is less possibilities to handle errors, because we can't use any hook e.g. to display an error dialog and we can only rely on toasts. However, we can define our own hook that extends basic definition of the useMutation hook from the library where we have the possibilities to use other hooks to handle errors in a different way. So let's define our custom useMutation hook.

export function useMutationWithErrorHandling<
  TData = unknown,
  TError = unknown,
  TVariables = void,
  TContext = unknown
>(
  mutationFn: MutationFunction<TData, TVariables>,
  options?: Omit<
    UseMutationOptions<TData, TError, TVariables, TContext>,
    "mutationFn"
  >
) {
  const { handleShowDialog } = useDialogContext();

  const showDialog = useCallback(
    (message: string) => {
      handleShowDialog(<CustomDialog message={message} />);
    },
    [handleShowDialog]
  );

  const mutation: UseMutationResult<TData, TError, TVariables, TContext> =
    useMutation(mutationFn, {
      ...options,
      onError: (error) => {
        if (!axios.isAxiosError(error)) throw Error("Unknown error");

        const errorCode = handleErrorCode(error);
        const errorMessage = handleErrorMessage(error);

        switch (errorCode) {
          case 400:
            toast.error(errorMessage);
            break;
          case 404:
          case 500:
            showDialog(errorMessage);
            break;
          default:
            throw Error(errorMessage);
        }
      },
      useErrorBoundary: (error) => {
        const errorCode = handleErrorCode(error);

        switch (errorCode) {
          case 400:
          case 404:
          case 500:
            return false;
          default:
            return true;
        }
      },
    });

  return mutation;
}

In our hook the types are based on the basic definition of the useMutation. Additionally, we can see difference in how the error is handled, as the toast will only be displayed for a 400 error code and a different error code will be handled by the dialog. Also, this hook uses error boundary for other errors that aren't handled in onError method. Finally, I recommend the react-error-boundary library that can handle it.

Example

I would like to present an example of handling errors in difference ways with using the mutation cache, the useMutation hook and our custom useMutation hook, so let's move to the code.

// Mutation Cache and ErrorBoundary
const App = () => {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        mutationCache: new MutationCache({
          onError: (error, _variables, _context, mutation) => {
            if (mutation.options.onError) return;

            const errorMessage = handleErrorMessage(error);
            toast.error(errorMessage);
          },
        }),
      })
  );

  return (
    <QueryClientProvider client={queryClient}>
      <Toaster position="bottom-center" containerClassName="ml-64" />
      <ErrorBoundary fallback={<div>Something went wrong</div>}>
        // Other elements
      </ErrorBoundary>
    </QueryClientProvider>
  );
};

export default App;

// useMutation
export function useMutationNotFoundException() {
  const mutation = useMutation(
    () => commentApi.commentExceptionNotFoundPost(),
    {
      mutationKey: [MutationKeys.NotFoundException],
      onSuccess: () => {},
      onError: () => {
        toast.error("Changed global error handling");
      },
    }
  );
  return mutation;
}

// useMutationWithErrorHandling
export function useMutationServerException() {
  const mutation = useMutationWithErrorHandling(
    () => commentApi.commentExceptionInternalPost(),
    {
      mutationKey: [MutationKeys.ServerException],
      onSuccess: () => {},
    }
  );
  return mutation;
}

To sum up, the presented solution can be an excellent base for expanding error handling when using the react-query library. In addition, I recommend using your own hook for error handling and the error boundary for unhandled errors.

The repositories with examples are available on my github at https://github.com/DamZyl/example-front and https://github.com/DamZyl/example-api.

Share the happiness :)