Aman Explains

Settings

How to use Error Boundary with React Query and Router v6

August 11, 2024• ☕️ 4 min read

Why Error Boundary

React Error boundary component captures errors during rendering and allows you to display fallback UI instead of crashing the whole app. You can either have a Global error boundary component or be granular by wrapping specific components with error boundary. This way your users won’t see a blank screen when the app crashes in production.

The ErrorBoundary component only captures errors that happen during rendering — not when they happen asynchronously. As per the docs, React error boundaries don’t catch errors that occur:

  • inside event handlers
  • inside asynchronous code (e.g. setTimeout callbacks)
  • server-side rendering
  • error thrown in the error boundary itself

Error handling in React Router v6

If you have upgraded your React app to React Router v6, you can also use the errorElement API to display nice UI when errors happen during rendering, along with exceptions thrown in loaders and actions. Note that this feature only works in data router though.

<Route
  path="/dashboard"
  element={<Dashboard />}
  // instead of Dashboard, RouteErrorBoundary will render 
  // when error happens
  errorElement={<RouteErrorBoundary />}
/>;

const RouteErrorBoundary = () => {
  const error = useRouteError();
  return (
    <div role="alert">
      <h1>Sorry, an unexpected error has occurred.</h1>
      <p>{error.statusText || error.message}</p>
    </div>
  );
};

If you notice, we haven’t used the Class component Error boundary approach as mentioned in the React docs. Our RouteErrorBoundary is just a regular function component, and React Router would render this for us instead of the route’s element when an error happens.

It might be surprising that if you don’t provide an errorElement in your route tree, the error will bubble up and be handled by a default errorElement that will print the error messages and the stack trace in the production – not very helpful to the users and not good to look at. Read more about it in the Default Error Element section.

The default errorElement UI provided by React router is ugly and isn’t intended for end-user consumption. That’s why it’s always recommended to provide at least a root-level errorElement in your route tree before shipping your app to production.

Note that these stack traces in production will show up even if you have a top-level ErrorBoundary because the default errorElement will capture this error and it won’t bubble up to this ErrorBoundary. Believe me, even I was unaware of this for a while and was confused to see stack traces in the production app.

<ErrorBoundary>
  <Route
    path="/dashboard"
    element={<Dashboard />}
  </Route>
</ErrorBoundary>

With the example setup above, you won’t be seeing your ErrorBoundary fallback UI in production when an error happens during rendering. The reason is that the default errorElement will capture the error and it won’t bubble up to this ErrorBoundary.

Now, it’s time we leverage this feature along with React query’s throwOnError option and implement a nice error management technique.


Make Error Boundary picks up errors in React Query

If you are using Tanstack React query package to asynchronously manage your state in your React application, it’s very likely that you might not be using the React router’s loader approach.

By the way you can still use both React query and loader option if you like.

Note that React router is also capable of rendering the errorElement UI even when there’s an asynchronous error that occurs inside the loader. But if you are using React query, you might be relying on the isError or error state to check if there’s an error.

const Dasbhoard = () => {
  const { data, isLoading, isError } = useQuery({
    queryKey: ['todos'],
    queryFn: getTodos,
  });  

  if (isError) {
    return <div role="aler">Something went wrong</div>;
  }

  // Rest of your component ... 
} 

This error boolean flag indicates that the API has returned an error, e.g., 404, or 403 etc. These are not the rendering errors. This approach is OK, but you have to do it for every component that uses a query hook. What if there’s a way you can handle these errors in a single place, like an error boundary component? This is where throwOnError feature comes in handy.

As we learn the React error boundary component only captures errors that happen during rendering. In the case of the React router’s errorElement, if you are not throwing any exception inside actions or loaders, the errorElement will only capture errors that happen during rendering.

So how can we handle asynchronous errors (for e.g API response errors) in React query? The answer is to leverage the throwOnError option inside the React query hook. When we set throwOnError to true in the hook’s options object, it will not only capture the async error internally for us but also re-throw it in the next rendering cycle so that the Router’s errorElement catches it for us via useRouterError hook.

Note: when throwOnError option is set to true, the react query will re-throw the error in the next rendering cycle.

const Dasbhoard = () => {
  const { data, isLoading } = useQuery({
    queryKey: ['todos'],
    queryFn: getTodos,
    // error will bubble up to the nearest route's errorElement
    throwOnError: true,
  });  

  // Rest of the dashboard component ... 
} 

// Inside your Route --------
<Route
  path="/dashboard"
  element={<Dashboard />}
  // instead of Dashboard, RouteErrorBoundary will render when error happens
  errorElement={<RouteErrorBoundary />}
/>;

const RouteErrorBoundary = () => {
// Error thrown by React query hook will be captured here.
  const error = useRouteError();
  return (
    <div role="alert">
      <h1>Oops!</h1>
      <p>Sorry, an unexpected error has occurred.</p>
      <p>{error.statusText || error.message}</p>
    </div>
  );
};

There’s an awesome article on React query error handling that explains how to handle errors in React query. I would recommend you to read it.

You can take this further and render error fallback UI based on the error status code.

/**
 * With react-router-dom v6, we can instead provide a root level errorElement
 * that will be rendered if an error occurs when rendering a route/component.
 *
 * Ref: https://reactrouter.com/en/main/route/error-element
 */
const RouteErrorBoundary = () => {
  const error = useRouteError();

  if (error.response.status === 404) {
    return <NotFound />;
  }
  if (error.response.status === 403) {
    return <Forbidden />
  }
  // TODO: add other 4xx, 5xx errors here.
}

export default RouteErrorBoundary;


What about the usual class Error Boundary component?

With this built-in errorElement feature in React router v6, you can catch your error at the route/page level. But if you want to be very granular you can still use the Class Error Boundary component. The idea is that whenever an error occurs during rendering, it will bubble up to the nearest error boundary component; it can either be the component’s own ErrorBoundary or the route level errorElement.

You can throw an error from within an inner ErrorBoundary component and it bubbles up to be captured by the outer ErrorBoundary or errorElement up in the tree.

Another use case is when you want to capture these errors via Sentry or any other error-tracking tool of your choice. Sentry provides its own Sentry.ErrorBoundary component that abstracts the implementation of an error boundary component. This is useful for logging/capturing errors in your production app. Let’s combine everything we have learnt so far.

// App ----------
<Sentry.ErrorBoundary fallback={<AppErrorFallback />} showDialog>
  <Route
    path="/dashboard"
    element={<Dashboard />}
    // instead of Dashboard, RouteErrorBoundary will render when error happens
    errorElement={<RouteErrorBoundary />}
  </Route>
</ErrorBoundary>

const Dasbhoard = () => {
  const { data, isLoading, isError } = useQuery({
    queryKey: ['todos'],
    queryFn: getTodos,
    throwOnError: true,
  });  
  ....
} 

const RouteErrorBoundary = () => {
  const error = useRouteError();

  if (error instanceof ResponseError) {
    if (error.response.status === 404) {
      return <NotFound />;
    }
    // TODO: add other 4xx errors here. For example: 401, 403
  } else {
    // re-throw to let the parent Sentry.ErrorBoundary handles
    // it and logs it.
    console.log(error);
    throw error;
  }
};

Re-throwing an error if it’s not an instanceof ResponseError is important here. This will cause the error to bubble up and reach to the Sentry.ErrorBoundary component.


Summary

In this article, we explored that:

  • An errorElement API can be used to render error fallback at the route level.
  • If an errorElement is not provided for a route, the error will bubble up to the parent route till it’s captured by the default errorElement component provided by React router.
  • The default errorElement component is not very helpful to the end user and is not intended for production use. Your users will see a stack trace in production when an error happens during rendering.
  • React ErrorBoundary component only catches errors that happen during rendering — not when they occur asynchronously.
  • We can set throwOnError to true in query hook’s options object to internally re-throw the error during the next rendering cycle. This will cause the error to bubble up.
  • It’s completely OK to manually throw an error in the rendering phase. It will then be captured by the outer error boundary (either errorElement or the usual class ErrorBoundary component).

It’s impossible to have an application without errors. As a developer, it’s our responsibility to at least minimise the impact of errors so that the app can run smoothly without affecting our users.


References


Amandeep Singh

Written by Amandeep Singh. Developer @  Avarni  Sydney. Tech enthusiast and a pragmatic programmer.