Errors are an inevitable part of programming. Error boundaries are React's way of handling JavaScript errors in React components. Introduced in React 16, error boundaries are crucial for minimizing the moments when everything seems to break and no one understands why.

You might think we could just use a try...catch statement and print the error trace, but try...catch only works for imperative code and not the declarative code we write in React components. Additionally, even with a try...catch statement, it's hard to know where the error came from, which line of code made the app break, or which files were involved.

This article will explain how you can use error boundaries to get full control of your errors in JavaScript and React.

What Happened and Where Did It Happen?

An effective error boundary tells us both what happened and where it happened. For that, we need the Error object. If you have good error handling with an Error object, you'll get a full error stack. Let's showcase this with a button that crashes our app and prints an error:

import { useState } from "react";
 
const App = () => {
  const [error, setError] = useState(Error());
 
  const throwError = () => {
    throw Error("I'm an error");
  };
 
  const crash = () => {
    try {
      throwError();
    } catch (e) {
      setError(e);
      console.error(e);
    }
  };
 
  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        marginTop: 80,
      }}
    >
      <button onClick={crash}>Hi! I'm a button that crashes</button>
      <p style={{ color: "red" }}>{error.message}</p>
    </div>
  );
};
 
export default App;

You can see that everything is working just fine. You understand what happened and where it happened. The error message shows up in the UI and nothing has crashed. But what if we took the same approach for an HTTP request with axios?

  const crash = async () => {
    try {
      await axios.get("https://urlthatdoesnotexists.url")
    } catch (e) {
      setError(e);
      console.error(e);
    }
  };

This is worse. Now, we know what is happening, but not where it's happening. Thankfully, you can get around this by logging a static instance of an Error object instead of logging the error itself.

 const crash = async () => {
    try {
      await axios.get("https://urlthatdoesnotexists.url");
    } catch (e) {
      setError(e);
      console.error(Error(e.message ?? e));
    }
  };

Do We Need More?

try...catch statements can become really messy quickly, particularly if you're dealing with nested exceptions. You want to make sure that your application doesn't break, regardless of what your code is doing.

You can make everything much simpler with two handler functions that receive callbacks as their arguments: one function for synchronous calls and the other for asynchronous calls.

//handlers/exceptions.js
export const execute = (callback) => {
  try {
    const res = callback();
    return [res, null];
  } catch (err) {
    console.error(Error(err.message ?? err));
    // You can also log error messages to an error reporting service here
    return [null, err];
  }
};
 
export const executeAsync = async (callback) => {
  try {
    const res = await callback();
    return [res, null];
  } catch (err) {
    console.error(Error(err.message ?? err));
    // You can also log error messages to an error reporting service here
    return [null, err];
  }
};

Now, let's call the corresponding function in our app:

  const [error, setError] = useState(new Error());
 
  const fetchData = async () =>
    await axios.get("http://urlthatdoesnotexist.url");
 
  const crash = async () => {
    const [res, err] = await executeAsync(fetchData);
    if (err) return setError(err);
 
    //do something with result
  };

This approach allows us to have an error-prone application without having to wrap everything in endless try...catch statements. It's easy to make changes to the error handling process, too. All we need to do is edit the handler, which will update every component where error handling is needed.

Rendering Errors

What would happen if the error happens in the JSX part of our component and crashes our entire application? Let's say we're accessing a property with a null value (yes, we can control that using Optional Chaining, but let's pretend we can't).

const App = () => {
  const [error, setError] = useState({ message: "I'm an error message" });
 
  const crash = () => {
    setError(null);
  };
 
  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        marginTop: 80,
      }}
    >
      <button onClick={crash}>Hi! I'm a button that crashes</button>
      <p style={{ color: "red" }}>{error.message}</p>
    </div>
  );
};
 
export default App;

When we click the button that crashes, we're served a blank page.

We can control this by introducing an HOC that wraps our component within an error boundary.

import { useState } from "react";
import { errorBoundary } from "./ErrorBoundary";
 
const App = () => {
  const [error, setError] = useState({ message: "I'm an error message" });
 
  const crash = () => {
    setError(null);
  };
 
  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        marginTop: 80,
      }}
    >
      <button onClick={crash}>Hi! I'm a button that crashes</button>
      <p style={{ color: "red" }}>{error.message}</p>
    </div>
  );
};
 
export default errorBoundary(App);

But, if we don't want to use an HOC, we can also wrap a component with an ErrorBoundary component. The result will be the same:

import React from "react";
import ReactDOM from "react-dom";
import App from "./AppCrash";
import ErrorBoundary from "./ErrorBoundary";
 
ReactDOM.render(
  <React.StrictMode>
    <ErrorBoundary>
      <App />
    </ErrorBoundary>
  </React.StrictMode>,
  document.getElementById("root")
);

Now, we're controlling the exception. The app doesn't break and we're showing what we want to show when something crashes.

Error boundary file (here you are exporting both the Component and the HOC):

// hoc/ErrorBoundary.js
import { Component } from "react";
 
const ErrorView = ({ error, errorInfo }) => (
  <div>
    <h2>Something went wrong.</h2>
    <details>
      {error && error.toString()}
      <br />
      {errorInfo.componentStack}
    </details>
  </div>
);
 
export default class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { error: null, errorInfo: null };
  }
 
  componentDidCatch(error, errorInfo) {
    // Catch errors in any components below and re-render with error message
    this.setState({
      error: error,
      errorInfo: errorInfo,
    });
    // You can also log error messages to an error reporting service here
  }
 
  render() {
    const { error, errorInfo } = this.state;
    if (errorInfo) {
      // Error path
      return <ErrorView {...{ error, errorInfo }} />;
    }
    // Normally, just render children
    return this.props.children;
  }
}
 
export function errorBoundary(WrappedComponent) {
  return class extends ErrorBoundary {
    render() {
      const { error, errorInfo } = this.state;
      if (errorInfo) {
        // Error path
        return <ErrorView {...{ error, errorInfo }} />;
      }
      //Normally, just render wrapped component
      return <WrappedComponent {...this.props} />;
    }
  };
}

To Wrap Up

Every high-quality application must have great error handling to deal with unexpected events. Errors should be appropriately logged so they have no impact on the user's experience and so your colleagues (and yourself) can determine the root cause of any crash. Error boundaries are how you do this in React.