DEV Community

Tyler Smith
Tyler Smith

Posted on • Edited on

Error boundary causes React Router links to stop working

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; 
  }
}

Enter fullscreen mode Exit fullscreen mode

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; 
  }
}

Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
mcantor profile image
mcantor • Edited

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:

componentDidUpdate(prevProps, prevState): void {
        if (prevProps && prevProps.location.key !== this.props.location.key) {
            if (prevState.hasError === true) {
                this.setState({ hasError: false})
            }
        }
    }

Enter fullscreen mode Exit fullscreen mode

The only hitch is that ErrorBoundary doesn't by default get the location as a prop so you have to use withRouter:

interface Props extends RouteComponentProps {}
class ErrorBoundaryComponent extends Component<Props, State> {
...
}
 export const ErrorBoundary = withRouter(ErrorBoundaryComponent)
Enter fullscreen mode Exit fullscreen mode
Collapse
 
tylerlwsmith profile image
Tyler Smith

I'm glad that you found a way that works well for you!