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="alert">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 totrue
, 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 outerErrorBoundary
orerrorElement
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 defaulterrorElement
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
totrue
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 classErrorBoundary
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
- React Error boundary
- React router error element
- React query throwOnError
- React query error handling
- Sentry Error Boundary
- Throw is about control flow - not error handling
Written by Amandeep Singh. Developer @ Avarni Sydney. Tech enthusiast and a pragmatic programmer.