Back

Catching Errors in React with Error Boundaries

Catching Errors in React with Error Boundaries

Even the most flawless applications have runtime errors from time to time. The network might time out, some backend service might go down, or your users might provide you with some input that just doesn’t compute. Or – you know – bugs. But what’s the best way to handle errors to ensure your app is resilient, continues to be responsive and provide the best possible user experience?

This article is going to introduce you to the concept of error boundaries in React. We’ll look at what challenges they try to solve, what shortcomings they have, and how to implement them. Lastly, we’ll look at a small abstraction layer that makes error boundaries even better!

What is an error boundary?

Error boundaries is the React way to handle errors in your application. It lets you react and recover from runtime errors as well as providing a fallback user interface if applicable.

The idea behind error boundaries is that you can wrap any part of your application in a special component – a so-called error boundary – and if that part of your application experiences an uncaught error, it will be contained within that component. You can then show an error, report it to your favorite error reporting service, and try to recover if possible.

Error boundaries were introduced in React 16, and was one of the first features that came out of the React team’s Fiber rewrite effort. They are the only component you still need to write as a class component (so no hooks as of yet!), but should definitely be a part of any modern React application.

Note that even though you can create several error boundaries throughout your application, many applications tend to opt for a single one at the root level. You can go super-granular if you wish, but my experience tells me that a root level one often suffices.

My first error boundary

An error boundary is a regular class component that implements one (or both) of the following methods:

static getDerivedStateFromError(error)

This method returns a new state, based on the error caught. Typically, you will flip a state flag that tells the error boundary whether or not to provide a fallback user interface.

componentDidCatch(error, errorInfo)

This method is called whenever an error occurs. You can log the error (and any extra information) to your favorite error reporting service, try to recover from the error, and whatever else you need to do.

To show how this is implemented, let’s create one step by step. First - let’s create a regular class component.

class ErrorBoundary extends React.Component {
    render() {
    return this.props.children;
    }  
}

This component doesn’t do much at all - it just renders its children. Let’s log the error to an error service!

class ErrorBoundary extends React.Component {
    componentDidCatch(error, errorInfo) {
        errorService.log({ error, errorInfo });
    }
    render() {
        return this.props.children;
    }  
}

Now, whenever an error occurs for our users, we’re notified through our error reporting service. We’ll receive the error itself, as well as the full component stack where the error occurred. This is going to greatly simplify our bug fixing work later on!

However, we’re still breaking the application! That’s not great. Let’s provide a fallback “oops” UI. In order to do that, we need to track whether or not we’re in an erroneous state - and that’s where the static getDerivedStateFromError method comes in!

class ErrorBoundary extends React.Component {
    state = { hasError: false };
    static getDerivedStateFromError(error) {
        return { hasError: true };
    }
    componentDidCatch(error, errorInfo) {
        errorService.log({ error, errorInfo });
    }
    render() {
        if (this.state.hasError) {
            return <h1>Oops, we done goofed up</h1>;
        }
        return this.props.children;
    }  
}

And now we have a basic, yet functional error boundary!

Start using it

Now, let’s start using it. Simply wrap your root app component in your new ErrorBoundary component!

ReactDOM.render(
    <ErrorBoundary>
        <App />
    </ErrorBoundary>,
    document.getElementById('root')
)

Note that you might want to place your error boundaries so that it shows some basic layout (header, footer etc) as well.

Add a reset functionality!

Sometimes, errors like this happen when the UI gets in some funky state. Whenever an error occurs, the entire sub-tree of the error boundary is unmounted, which in turn will reset any internal state. Providing the user with a “Want to try again” button, which will try to remount the sub-tree with fresh state could be a good idea at times! Let’s do that next.

class ErrorBoundary extends React.Component {
    state = { hasError: false };
    static getDerivedStateFromError(error) {
        return { hasError: true };
    }
    componentDidCatch(error, errorInfo) {
        errorService.log({ error, errorInfo });
    }
    render() {
        if (this.state.hasError) {
            return (
            <div>
                <h1>Oops, we done goofed up</h1>
                <button type="button" onClick={() => this.setState({ hasError: false })}>
                Try again?
                </button>
            </div>
            );
        }
        return this.props.children;
    }  
}

Of course, this might not be a good idea for your application. Take your own needs and users into consideration when implementing features like this.

Open Source Session Replay

OpenReplay is an open-source, session replay suite that lets you see what users do on your web app, helping you troubleshoot issues faster. OpenReplay is self-hosted for full control over your data.

replayer.png

Start enjoying your debugging experience - start using OpenReplay for free.

Limitations

Error boundaries are great for what they do - catch runtime errors you didn’t expect during rendering. However, there are a few types of errors that aren’t caught, and that you need to deal with in a different way. These include:

  • errors in event handlers (when you click a button for instance)
  • errors in asynchronous callbacks (setTimeout for instance)
  • errors that happen in the error boundary component itself
  • errors that occur during server side rendering

These limitations might sound severe, but most of the time they can be worked around by using try-catch and a similar hasError state.

function SignUpButton(props) {
    const [hasError, setError] = React.useState(false);
    const handleClick = async () => {
        try {
            await api.signUp();
        } catch(error) {
            errorService.log({ error })
            setError(true);
        }
    }
    if (hasError) {
        return <p>Sign up failed!</p>;
    }
    return <button onClick={handleClick}>Sign up</button>;
}

This works well enough, even if you do have to duplicate a few lines of code.

Creating a better error boundary

Error boundaries are good by default, but it would be great to reuse their error handling logic in event handlers and asynchronous places as well. It’s easy enough to implement through the context API!

To give our error boundary super powers, let’s implement a function for triggering errors manually.

class ErrorBoundary extends React.Component {
    state = { hasError: false };
    static getDerivedStateFromError(error) {
        return { hasError: true };
    }
    componentDidCatch(error, errorInfo) {
        errorService.log({ error, errorInfo });
    }
    triggerError = ({ error, errorInfo }) => {
        errorService.log({ error, errorInfo });
        this.setState({ hasError: true });
    }
    resetError = () => this.setState({ hasError: false });
    render() {
        if (this.state.hasError) {
            return <h1>Oops, we done goofed up</h1>;
        }
        return this.props.children;
    }  
}

Next, let’s create a context and pass our new function into it:

const ErrorBoundaryContext = React.createContext(() => {});

We can then create a custom hook to retrieve the error triggering function from any child component:

const useErrorHandling = () => {
    return React.useContext(ErrorBoundaryContext)
}

Next, let’s wrap our error boundary in this context:

class ErrorBoundary extends React.Component {
    state = { hasError: false };
    static getDerivedStateFromError(error) {
        return { hasError: true };
    }
    componentDidCatch(error, errorInfo) {
        errorService.log({ error, errorInfo });
    }
    triggerError = ({ error, errorInfo }) => {
        errorService.log({ error, errorInfo });
        this.setState({ hasError: true });
    }
    resetError = () => this.setState({ hasError: false });
    render() {
        return (
            <ErrorBoundaryContext.Provider value={this.triggerError}>
            {this.state.hasError 
                ? <h1>Oops, we done goofed up</h1>
                : this.props.children
            }
            </ErrorBoundaryContext.Provider>
        );
    }  
}

Now we can trigger errors from our event handlers as well!

function SignUpButton(props) {
    const { triggerError } = useErrorHandling();
    const handleClick = async () => {
        try {
            await api.signUp();
        } catch(error) {
            triggerError(error);
        }
    }
    return <button onClick={handleClick}>Sign up</button>;
}

Now we don’t have to think about error reporting or creating a fallback UI for every click handler we implement - it’s all in the error boundary component.

Using react-error-boundary

Writing your own error boundary logic like we did above is fine, and should handle most use cases for you. However, this is a solved problem. React Core team member Brian Vaughn (and later, the very talented React teacher Kent C. Dodds) have spent a bit of time creating a [react-error-boundary](https://www.npmjs.com/package/react-error-boundary) npm package that gives you pretty much the same thing as above.

The API is a bit different, so you can pass in custom fallback components and reset logic instead of writing your own, but it’s used in a very similar way. Here’s an example:

ReactDOM.render(
    <ErrorBoundary 
        FallbackComponent={MyFallbackComponent}
        onError={(error, errorInfo) => errorService.log({ error, errorInfo })}
    >
        <App />
    </ErrorBoundary>,
    document.getElementById('root')
)

You can also have a look at how it’s implemented - it uses a different approach to triggering errors from hooks, but otherwise it works pretty much the same way.

Summary

Handling errors and unexpected events is crucial for any quality application. It’s extremely important to provide a great user experience, even when things doesn’t go as planned.

Error boundaries is a great way to make your application fail gracefully, and even contain errors to parts of your application while the rest continues to work! Write your own, or adopt the react-error-boundary library to do it for you. No matter what you choose, your users will thank you!