Skip to content

Instantly share code, notes, and snippets.

@donaldpipowitch
Last active June 13, 2022 04:24
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save donaldpipowitch/1248ca4658506c7c8b481edfbd740ca7 to your computer and use it in GitHub Desktop.
Save donaldpipowitch/1248ca4658506c7c8b481edfbd740ca7 to your computer and use it in GitHub Desktop.
React - Error Boundary strategy

One cool feature of React which isn't highlighted that often are "error boundaries".

Error boundaries can catch errors which are thrown inside your component during a lifecycle. You can use them in very fine-granular levels wrapping very small components (but I rarely see this) or you can wrap your whole app and show some fallback content, if an error happens. But you can also wrap something in between those extrem ranges and add a proper reset. Also it's not a hidden secret how to do that I haven't see a lot of people talking about that. So here is a small example which use react-router-dom to do that. You can see the complete example here.

Let's imagine you have the following app:

import * as React from 'react';
import { render } from 'react-dom';
import { Link, BrowserRouter, Switch, Route, Redirect } from 'react-router-dom';

const Header = () => (
  <header>
    <h1>Cool Example</h1>
    <p>
      <Link to="/section-a">Section A</Link>
      {' | '}
      <Link to="/section-b">Section B</Link>
      {' | '}
      <Link to="/section-c">Section C</Link>
    </p>
    <hr />
  </header>
);

const SectionA = () => (
  <p>
    You see "Section A". If you want to visit "Section C" an error will be
    thrown.
  </p>
);

const SectionB = () => <p>You see "Section B".</p>;

const SectionC = () => {
  throw new Error('uh oh');
};

const App = () => {
  return (
    <BrowserRouter>
      <Header />

      <Switch>
        <Route path="/section-a">
          <SectionA />
        </Route>

        <Route path="/section-b">
          <SectionB />
        </Route>

        <Route path="/section-c">
          <SectionC />
        </Route>

        <Redirect to="/section-a" />
      </Switch>
    </BrowserRouter>
  );
};

const rootElement = document.getElementById('root');
render(<App />, rootElement);

Your <App/> has a <Header/> containing three <Link/>'s. If you click on a link <Link/> you can see different section of your <App/>. But clicking on "Section C" will throw an error leaving you with a blank page.

Error boundaries to the rescue. If you have an evolving app, it's very likely that you header <Header/> is better tested than a blank new and complex section. You also just want to let the error prone section fail and show a working <Header/> to the user, so the user has the opportunity to at least visit other parts of the <App/>. A simple approach could look like this:

+class ErrorBoundary extends React.Component<
+  { children?: React.ReactNode },
+  { error: unknown }
+> {
+  state = { error: undefined };
+
+  static getDerivedStateFromError(error: unknown) {
+    return { error };
+  }
+
+  render() {
+    if (this.state.error) {
+      return <p>An unknown error happened.</p>;
+    } else {
+      return this.props.children;
+    }
+  }
+}

const App = () => {
  return (
    <BrowserRouter>
      <Header />

+      <ErrorBoundary>
        <Switch>
          <Route path="/section-a">
            <SectionA />
          </Route>

          <Route path="/section-b">
            <SectionB />
          </Route>

          <Route path="/section-c">
            <SectionC />
          </Route>

          <Redirect to="/section-a" />
        </Switch>
+      </ErrorBoundary>
    </BrowserRouter>
  );
};

If you click on "Section C" now, you won't see a blank page anymore, but a simple error message and the <Header/>. Sadly the <Header/> doesn't work correctly. If you click a <Link/> the URL will change, but you won't see a new section. We need to reset the error boundary by using a key which only changes for URL changes after an error happened. We don't want to remount the error boundary on every URL change to avoid unwanted side-effects (and performance issues). This can be achieved by wrapping the error boundary itself.

-class ErrorBoundary extends React.Component<
+class RealErrorBoundary extends React.Component<
-  { children?: React.ReactNode },
+  { children?: React.ReactNode; setTrackPathChange: (track: boolean) => void },
  { error: unknown }
> {
  state = { error: undefined };

  static getDerivedStateFromError(error: unknown) {
    return { error };
  }

+  componentDidCatch(error: unknown, info: React.ErrorInfo) {
+    this.props.setTrackPathChange(true);
+  }

  render() {
    if (this.state.error) {
      return <p>An unknown error happened.</p>;
    } else {
      return this.props.children;
    }
  }
}

+function usePrevious<T>(value: T) {
+  const ref = React.useRef(value);
+
+  React.useEffect(() => {
+    ref.current = value;
+  }, [value]);
+
+  return ref.current;
+}

+// this "fake" error boundary will reset the "real" error boundary
+// whenever a pathname change happens _after_ an error
+const ErrorBoundary: React.FC = ({ children }) => {
+  const [key, setKey] = React.useState(0);
+  const { pathname } = useLocation();
+  const previousPathname = usePrevious(pathname);
+  const [trackPathChange, setTrackPathChange] = React.useState(false);
+
+  React.useEffect(() => {
+    if (trackPathChange && previousPathname !== pathname) {
+      setKey((key) => key + 1);
+      setTrackPathChange(false);
+    }
+  }, [trackPathChange, previousPathname, pathname]);
+
+  return (
+    <RealErrorBoundary key={key} setTrackPathChange={setTrackPathChange}>
+      {children}
+    </RealErrorBoundary>
+  );
+};

Now you can use the <Header/> like you're used to. But what if the <Header/> has a bug and throws an error? As the last resort you can introduce another error boundary for non-recoverable errors where the user can only try to reload the whole application and hope for the best.

+class FatalErrorBoundary extends React.Component<
+  { children?: React.ReactNode },
+  { error: unknown }
+> {
+  state = { error: undefined };
+
+  static getDerivedStateFromError(error: unknown) {
+    return { error };
+  }
+
+  render() {
+    if (this.state.error) {
+      return (
+        <p>
+          A fatal error happened. You can only try ro reload.
+          <button onClick={() => window.location.reload()}>Reload</button>
+        </p>
+      );
+    } else {
+      return this.props.children;
+    }
+  }
+}

const App = () => {
  return (
+    <FatalErrorBoundary>
      <BrowserRouter>
        <Header />

        <ErrorBoundary>
          <Switch>
            <Route path="/section-a">
              <SectionA />
            </Route>

            <Route path="/section-b">
              <SectionB />
            </Route>

            <Route path="/section-c">
              <SectionC />
            </Route>

            <Redirect to="/section-a" />
          </Switch>
        </ErrorBoundary>
      </BrowserRouter>
+    </FatalErrorBoundary>
  );
};

Of course this is not ideal, but better than a blank page. Furthermore you could try to check the error and show case more fine-granular error messages depending on your error, show a feedback form or whatever. (And if you do know about recoverable errors in the <Header/>, you could add a header specific error boundary as well.)

I hope you learned something in this small example and if not at least have a small error boundary example you can easily copy and paste in your projects.

Bye 👋

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment