React Error Boundaries

A better UI for error handling

React Error Boundaries

My error handling so far

There are quite a few ways to approach error handling, but one I have began to explore more fully is the concept of error boundaries. So in today’s blog I’ll briefly summarise what they do, how to implement them, and why they are useful specifically in a React application.

In my application so far, I have looked at handling route-level errors with React Router, by presenting error elements - fallback UI elements which display when a request to a specific route fails. My backend validation also involves handling async requests more gracefully using Express middleware, using these to catch unresolved Promises and thereby ‘throw’ an error element, without crashing the app.

Introducing error boundaries

Error boundaries extend this further, making an app more robust as well as allowing for some more granular control of how specific components display in the event of an error.

They are specifically designed for catching runtime errors in the rendering phase of React components, and can be useful as another safeguard for uncaught JS errors, failed API requests, async errors and other edge cases.

You may also have noticed that when a part of your application fails - unless they are caught by some form of validation or error handler (such as those mentioned above) - your entire UI will crash. No-one wants to see this:

So, let’s look at a way of offering better visual feedback to both the user and the developer, while keeping your core app running in the event of any component-specific problems.

Creating a boundary

Here’s a basic implementation of an error boundary, extending the base class of a React component (the interface and typing are specific to TypeScript, ignore if using plain JS):

import React, { ReactNode } from "react";

interface ErrorBoundaryProps {
  children: ReactNode;
  fallback: ReactNode;
}

interface ErrorBoundaryState {
  hasError: boolean;
}

class ErrorBoundary extends React.Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false };
  }
  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { hasError: true };
  }
  componentDidCatch(error: Error, info: React.ErrorInfo): void {
    console.log(error, info);
  }
  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

export default ErrorBoundary;

(To save you writing this each time, there are single-component libraries available such as this - but it’s always good to understand what’s going on under the hood).

The first function, getDerivedStateFromError(), ensures that the error state is changed to 'true' on throwing any error.

The second function, componentDidCatch(), can be used to handle the error - in this case simply logging the error and info. You can make this as complex as you like.

Now you can import this and use it as a JSX/TSX component, wrapping any elements for which you want to provide a fallback:

import ErrorBoundary from "./ErrorBoundary"

<ErrorBoundary fallback="Error">
    <Card variation="Index" />
</ErrorBoundary>

If an error is triggered by the specific component(s) within this boundary, we should see whatever is passed in to the fallback prop as the replacement UI element.

In this case, we simply have some text saying “Error”, but we can improve this by passing in a custom fallback element:

import FallbackCard from "./components/FallbackCard"

<ErrorBoundary fallback={<FallbackCard />}>
    <Card variation="Index" />
</ErrorBoundary>

To test this out, a useful method is to use React Developer Tools in your browser, and inspect the element you would like to test. You can then click the option to ‘force the selected component into an errored state’:

Other testing options to explore for a more automated approach would be using React Testing Library with a framework/assertion library like Jest

In my case, I wanted my fallback card to display using a similar layout to other cards, in keeping with their design:

If you’re familiar with React Router’s error elements, you may see some similarities conceptually here, but there are key differences:

  • React Router's error elements are limited to errors occurring during the routing lifecycle (data loading errors, route rendering errors, etc.). So these are more route-specific errors.

  • React error boundaries are used to catch JS errors in specific parts of the component tree during rendering, lifecycle methods, and constructors. So these are more component-specific errors.

A more robust app will therefore usually incorporate both - they aren’t fully interchangeable.

Which brings us to the question of: where is best to use error boundaries?

Where to use error boundaries

This is something I had to consider carefully and will probably refine as I continue developing this app, as well as others.

There are a lot of options here depending on your specific app’s structure and how granular you want to be with your components. Do you want each individual element to have its own fallback wrapper, or would you prefer to wrap a group of components with a single fallback component displaying if any of them fail?

Overall, there is usually a balancing act somewhere between two extremes:

  • ‘Not enough’ error boundaries - for example, wrapping your entire app in a single error boundary - meaning any error within the entire component tree will trigger your fallback:

      ReactDOM.createRoot(document.getElementById("root")!).render(
        <React.StrictMode>
          <ErrorBoundary fallback={<ErrorDisplay />}>
            <App />
          </ErrorBoundary>
        </React.StrictMode>
      );
    

    This is akin to setting up a ‘Not Found’ page at the route level, which certainly looks better than an error stack trace or empty page - but it also means that your entire app is unusable in the event of any thrown error.

  • ‘Too many’ error boundaries - the other extreme would be wrapping all possible components with their own individual error boundary. As well as an increased performance overhead, this can create an unhelpful UX - say part of an interface element crashed, would you want the rest of that interface to still render with a small ‘error’ notification inside, or would it not make sense to disable it entirely?

    You would also need to consider how your fallback components display within your page - depending on how carefully each of these are setup, the layout may change or even ‘break.’

So what’s the answer? As with many things in life, there isn’t one - but one good approach here is to consider how your app is broken up conceptually into ‘features.’ Think about what makes sense grouped together - if it doesn’t make sense to the user to have only part of a component functioning, then perhaps consider wrapping the entire component or feature in a single error boundary.

There’s a great deep dive on this in Brandon Dail’s blog on ‘Fault Tolerance’ here.

I hope you’ve found this intro useful - next I’ll be moving on slightly from error handling, and tackling the <Suspense> component for handling loading states.