Yesterday I was coding a React error boundary that wrapped most of the components in my app. When one of its child components had an error, it caught the error and rendered an error message as expected. However, when I clicked a link to navigate away, all of my links were broken.
Below is a stripped down version of what my app looked like. If I tried to navigate to the /page-with-error
route, I'd get the error screen because the page had a runtime error. However, when I tried to navigate from the error screen back home, I'd be stuck on the error screen.
import React from 'react'
import { BrowserRouter, Link, Routes, Route } from "react-router-dom";
import Homepage from './Homepage';
import PageWithError from './PageWithError';
export default function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>{" "}
<Link to="/page-with-error">Broken Page</Link>
</nav>
<ErrorBoundary>
<Routes>
<Route path="/" element={<Homepage />} />
<Route path="/page-with-error" element={<PageWithError />} />
</Routes>
</ErrorBoundary>
</BrowserRouter>
);
}
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
return this.state.hasError
? <h1>Something went wrong.</h1>
: this.props.children;
}
}
Why navigation doesn't work
Upon closer inspection, it turns out that the reason I can't navigate is because the <ErrorBoundary />
component's hasError
state is still set to true after I navigate, so the error boundary continues to show the error no matter what page I navigate to.
The easiest way to handle this would be to trigger a side-effect that switches hasError
to false whenever the URL location changes.
Unfortunately, React's default way of handling side-effects is a hook: useEffect
. Hooks aren't available in class components, and you can't build a an error boundary without using a class component.
The solution
If this seems insurmountable, fear not: we can compose a functional component and a class-based error boundary component together to dismiss the error when the route changes.
import React, { useState, useEffect } from 'react'
import { BrowserRouter, Link, useLocation, Routes, Route } from "react-router-dom";
import Homepage from './Homepage';
import PageWithError from './PageWithError';
export default function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">Home</Link>{" "}
<Link to="/page-with-error">Broken Page</Link>
</nav>
<ErrorBoundary>
<Routes>
<Route path="/" element={<Homepage />} />
<Route path="/page-with-error" element={<PageWithError />} />
</Routes>
</ErrorBoundary>
</BrowserRouter>
);
}
/**
* NEW: The error boundary has a function component wrapper.
*/
function ErrorBoundary({children}) {
const [hasError, setHasError] = useState(false);
const location = useLocation();
useEffect(() => {
if (hasError) {
setHasError(false);
}
}, [location.key]);
return (
/**
* NEW: The class component error boundary is now
* a child of the functional component.
*/
<ErrorBoundaryInner
hasError={hasError}
setHasError={setHasError}
>
{children}
</ErrorBoundaryInner>
);
}
/**
* NEW: The class component accepts getters and setters for
* the parent functional component's error state.
*/
class ErrorBoundaryInner extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(_error) {
return { hasError: true };
}
componentDidUpdate(prevProps, _previousState) {
if(!this.props.hasError && prevProps.hasError) {
this.setState({ hasError: false });
}
}
componentDidCatch(_error, _errorInfo) {
this.props.setHasError(true);
}
render() {
return this.state.hasError
? <h1>There was an error</h1>
: this.props.children;
}
}
How it works
In the example above, a functional component wraps the class component error boundary. Just like before, the class component error boundary catches any child errors. When a child error is caught, it will use the componentDidCatch()
lifecycle method to set the error state of the parent functional component.
When React Router's location changes, the parent functional component will dismiss its error state within the useEffect
hook, and it will pass new props into the child component. This in turn will trigger the componentDidUpdate()
lifecycle method and dismiss the class component error boundary error state, allowing the new screen to render when the route changes.
Parting thoughts
This implementation is hairy and a little confusing, but it works. You could avoid this complexity by setting error boundaries per route rather than near the top of the application. However, if you're looking for a catch all handler that won't break your application's links, this should do the trick.
Top comments (2)
Thanks for this - great solution! This didn't work for me because of the way i have to expose my error handler to other parts of my code, but I found an alternative approach you or others might be interested in. The general idea is to check if the location has changed in
ComponentDidUpdate
:The only hitch is that
ErrorBoundary
doesn't by default get the location as a prop so you have to usewithRouter
:I'm glad that you found a way that works well for you!