How to handle errors in React: full guide

adevnadia

Nadia Makarevich

Posted on February 23, 2023

How to handle errors in React: full guide

Image description

Originally published at https://www.developerway.com. The website has more articles like this 😉


We all want our apps to be stable, to work perfectly, and cater to every edge case imaginable, isn’t it? But the sad reality is we are all humans (at least that is my assumption), we all make mistakes, and there is no such thing as a bug-free code. No matter how careful we are or how many automated tests we write, there always will be a situation when something goes terribly wrong. The important thing, when it comes to user experience, is to predict that terrible thing, localize it as much as possible, and deal with it in a graceful way until it can be actually fixed.

So today, let’s take a look at error handling in React: what we can do if an error happens, what are the caveats of different approaches to error catching, and how to mitigate them.

Why we should catch errors in React

But first thing first: why it’s vitally important to have some error-catching solution in React?

The answer is simple: starting from version 16, an error thrown during React lifecycle will cause the entire app to unmount itself if not stopped. Before that, components would be preserved on the screen, even if malformed and misbehaved. Now, an unfortunate uncaught error in some insignificant part of the UI, or even some external library that you have no control over, can destroy the entire page and render an empty screen for everyone.

Never before had frontend developers such destructive power 😅

Remembering how to catch errors in javascript

When it comes to catching those nasty surprises in regular javascript, the tools are pretty straightforward.

We have our good old try/catch statement, which is more or less self-explanatory: try to do stuff, and if they fail - catch the mistake and do something to mitigate it:

try {
  // if we're doing something wrong, this might throw an error
  doSomething();
} catch (e) {
  // if error happened, catch it and do something with it without stopping the app
  // like sending this error to some logging service
}
Enter fullscreen mode Exit fullscreen mode

This also will work with async function with the same syntax:

try {
  await fetch('/bla-bla');
} catch (e) {
  // oh no, the fetch failed! We should do something about it!
}
Enter fullscreen mode Exit fullscreen mode

Or, if we’re going with the old-school promises, we have a catch method specifically for them. So if we re-write the previous fetch example with promised-based API, it will look like this:

fetch('/bla-bla').then((result) => {
  // if a promise is successful, the result will be here
  // we can do something useful with it
}).catch((e) => {
  // oh no, the fetch failed! We should do something about it!
})
Enter fullscreen mode Exit fullscreen mode

It’s the same concept, just a bit different implementation, so for the rest of the article I’m just going to use try/catch syntax for all errors.

Simple try/catch in React: how to and caveats

When an error is caught, we need to do something with it, right? So, what exactly can we do, other than logging it somewhere? Or, to be more precise: what can we do for our users? Just leaving them with an empty screen or broken interface is not exactly user-friendly.

The most obvious and intuitive answer would be to render something while we wait for the fix. Luckily, we can do whatever we want in that catch statement, including setting the state. So we can do something like this:

const SomeComponent = () => {
  const [hasError, setHasError] = useState(false);

  useEffect(() => {
    try {
      // do something like fetching some data
    } catch(e) {
      // oh no! the fetch failed, we have no data to render!
      setHasError(true);
    }
  })

  // something happened during fetch, lets render some nice error screen
  if (hasError) return <SomeErrorScreen />

  // all's good, data is here, let's render it
  return <SomeComponentContent {...datasomething} />
}
Enter fullscreen mode Exit fullscreen mode

We’re trying to send a fetch request, if it fails - setting the error state, and if the error state is true, then we render an error screen with some additional info for users, like a support contact number.

This approach is pretty straightforward and works great for simple, predictable, and narrow use cases like catching a failed fetch request.

But if you want to catch all errors that can happen in a component, you’ll face some challenges and serious limitations.

Limitation 1: you will have trouble with useEffect hook.

If we wrap useEffect with try/catch, it just won’t work:

try {
  useEffect(() => {
    throw new Error('Hulk smash!');
  }, [])
} catch(e) {
  // useEffect throws, but this will never be called
}
Enter fullscreen mode Exit fullscreen mode

It’s happening because useEffect is called asynchronously after render, so from try/catch perspective everything went successfully. It’s the same story as with any Promise: if we don’t wait for the result, then javascript will just continue with its business, return to it when the promise is done, and only execute what is inside useEffect (or then of a Promise). try/catch block will be executed and long gone by then.

In order for errors inside useEffect to be caught, try/catch should be placed inside as well:

useEffect(() => {
 try {
   throw new Error('Hulk smash!');
 } catch(e) {
   // this one will be caught
 }
}, [])
Enter fullscreen mode Exit fullscreen mode

Play around with this example to see it:

This applies to any hook that uses useEffect or to anything asynchronous really. As a result, instead of just one try/catch that wraps everything, you’d have to split it into multiple blocks: one for each hook.

Limitation 2: children components. try/catch won’t be able to catch anything that is happening inside children components. You can’t just do this:

const Component = () => {
  let child;

  try {
    child = <Child />
  } catch(e) {
    // useless for catching errors inside Child component, won't be triggered
  }

  return child;
}
Enter fullscreen mode Exit fullscreen mode

or even this:

const Component = () => {
  try {
    return <Child />
  } catch(e) {
    // still useless for catching errors inside Child component, won't be triggered
  }
}
Enter fullscreen mode Exit fullscreen mode

Play around with this example to see it:

This is happening because when we write <Child /> we’re not actually rendering this component. What we’re doing is creating a component Element, which is nothing more than a component’s definition. It’s just an object that contains necessary information like component type and props, that will be used later by React itself, which will actually trigger the render of this component. And it will happen after try/catch block is executed successfully, exactly the same story as with promises and useEffect hook.

If you’re curious to learn in more detail how elements and components work, here is the article for you: The mystery of React Element, children, parents and re-renders

Limitation 3: setting state during render is a no-no

If you’re trying to catch errors outside of useEffect and various callbacks (i.e. during component’s render), then dealing with them properly is not that trivial anymore: state updates during render are not allowed.

Simple code like this, for example, will just cause an infinite loop of re-renders, if an error happens:

const Component = () => {
  const [hasError, setHasError] = useState(false);

  try {
    doSomethingComplicated();
  } catch(e) {
    // don't do that! will cause infinite loop in case of an error
    // see codesandbox below with live example
    setHasError(true);
  }
}
Enter fullscreen mode Exit fullscreen mode

Check it out in codesandbox

We could, of course, just return the error screen here instead of setting state:

const Component = () => {
  try {
    doSomethingComplicated();
  } catch(e) {
    // this allowed
    return <SomeErrorScreen />
  }
}
Enter fullscreen mode Exit fullscreen mode

But that, as you can imagine, is a bit cumbersome, and will force us to handle errors in the same component differently: state for useEffect and callbacks, and direct return for everything else.

// while it will work, it's super cumbersome and hard to maitain, don't do that
const SomeComponent = () => {
  const [hasError, setHasError] = useState(false);

  useEffect(() => {
    try {
      // do something like fetching some data
    } catch(e) {
      // can't just return in case of errors in useEffect or callbacks
      // so have to use state
      setHasError(true);
    }
  })

  try {
    // do something during render
  } catch(e) {
    // but here we can't use state, so have to return directly in case of an error
    return <SomeErrorScreen />;
  }

  // and still have to return in case of error state here
  if (hasError) return <SomeErrorScreen />

  return <SomeComponentContent {...datasomething} />
}
Enter fullscreen mode Exit fullscreen mode

To summarise this section: if we rely solely on try/catch in React, we will either miss most of the errors, or will turn every component into an incomprehensible mess of code that will probably cause errors by itself.

Luckily, there is another way.

React ErrorBoundary component

To mitigate the limitations from above, React gives us what is known as “Error Boundaries”: a special API that turns a regular component into a try/catch statement in a way, only for React declarative code. Typical usage that you can see in every example over there, including React docs, will be something like this:

const Component = () => {
  return (
    <ErrorBoundary>
      <SomeChildComponent />
      <AnotherChildComponent />
    </ErrorBoundary>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now, if something goes wrong in any of those components or their children during render, the error will be caught and dealt with.

But React doesn’t give us the component per se, it just gives us a tool to implement it. The simplest implementation would be something like this:

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    // initialize the error state
    this.state = { hasError: false };
  }

  // if an error happened, set the state to true
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  render() {
    // if error happened, return a fallback component
    if (this.state.hasError) {
      return <>Oh no! Epic fail!</>
    }

    return this.props.children;
  }
}
Enter fullscreen mode Exit fullscreen mode

We create a regular class component (going old-school here, no hooks for error boundaries available) and implement getDerivedStateFromError method - that turns the component into a proper error boundary.

Another important thing to do when dealing with errors is to send the error info somewhere where it can wake up everyone who’s on-call. For this, error boundaries give us componentDidCatch method:

class ErrorBoundary extends React.Component {
  // everything else stays the same

  componentDidCatch(error, errorInfo) {
    // send error to somewhere here
    log(error, errorInfo);
  }
}
Enter fullscreen mode Exit fullscreen mode

After the error boundary is set up, we can do whatever we want with it, same as any other component. We can, for example, make it more re-usable and pass the fallback as a prop:

render() {
  // if error happened, return a fallback component
  if (this.state.hasError) {
    return this.props.fallback;
  }

  return this.props.children;
}
Enter fullscreen mode Exit fullscreen mode

And use it like this:

const Component = () => {
  return (
    <ErrorBoundary fallback={<>Oh no! Do something!</>}>
      <SomeChildComponent />
      <AnotherChildComponent />
    </ErrorBoundary>
  )
}
Enter fullscreen mode Exit fullscreen mode

Or anything else that we might need, like resetting state on a button click, differentiating between types of errors, or pushing that error to a context somewhere.

See full example here:

There is one caveat in this error-free world though: it doesn’t catch everything.

ErrorBoundary component: limitations

Error boundary catches only errors that happen during React lifecycle. Things that happen outside of it, like resolved promises, async code with setTimeout, various callbacks and event handlers, will just disappear if not dealt with explicitly.

const Component = () => {
  useEffect(() => {
    // this one will be caught by ErrorBoundary component
    throw new Error('Destroy everything!');
  }, [])

  const onClick = () => {
    // this error will just disappear into the void
    throw new Error('Hulk smash!');
  }

  useEffect(() => {
    // if this one fails, the error will also disappear
    fetch('/bla')
  }, [])

  return <button onClick={onClick}>click me</button>
}

const ComponentWithBoundary = () => {
  return (
    <ErrorBoundary>
      <Component />
    </ErrorBoundary>
  )
}
Enter fullscreen mode Exit fullscreen mode

The common recommendation here is to use regular try/catch for that kind of errors. And at least here we can use state safely (more or less): callbacks of event handlers are exactly the places where we usually set state anyway. So technically, we can just combine two approaches and do something like this:

const Component = () => {
  const [hasError, setHasError] = useState(false);

  // most of the errors in this component and in children will be caught by the ErrorBoundary

  const onClick = () => {
    try {
      // this error will be caught by catch
      throw new Error('Hulk smash!');
    } catch(e) {
      setHasError(true);
    }
  }

  if (hasError) return 'something went wrong';

  return <button onClick={onClick}>click me</button>
}

const ComponentWithBoundary = () => {
  return (
    <ErrorBoundary fallback={"Oh no! Something went wrong"}>
      <Component />
    </ErrorBoundary>
  )
}
Enter fullscreen mode Exit fullscreen mode

But. We’re back to square one: every component needs to maintain its “error” state and more importantly - make a decision on what to do with it.

We can, of course, instead of dealing with those errors on a component level just propagate them up to the parent that has ErrorBoundary via props or Context. That way at least we can have a “fallback” component in just one place:

const Component = ({ onError }) => {
  const onClick = () => {
    try {
      throw new Error('Hulk smash!');
    } catch(e) {
      // just call a prop instead of maintaining state here
      onError();
    }
  }

  return <button onClick={onClick}>click me</button>
}

const ComponentWithBoundary = () => {
  const [hasError, setHasError] = useState();
  const fallback = "Oh no! Something went wrong";

  if (hasError) return fallback;

  return (
    <ErrorBoundary fallback={fallback}>
      <Component onError={() => setHasError(true)} />
    </ErrorBoundary>
  )
}
Enter fullscreen mode Exit fullscreen mode

But it’s so much additional code! We’d have to do it for every child component in the render tree. Not to mention that we’re basically maintaining two error states now: in the parent component, and in ErrorBoundary itself. And ErrorBoundary already has all the mechanisms in place to propagate the errors up the tree, we’re doing double work here.

Can’t we just catch those errors from async code and event handlers with ErrorBoundary instead?

Catching async errors with ErrorBoundary

Interestingly enough - we can catch them all with ErrorBoundary! Everyone’s favorite Dan Abramov shares with us a cool hack to achieve exactly that: Throwing Error from hook not caught in error boundary · Issue #14981 · facebook/react.

The trick here is to catch those errors first with try/catch, then inside catch statement trigger normal React re-render, and then re-throw those errors back into the re-render lifecycle. That way ErrorBoundary can catch them as any other error. And since state update is the way to trigger re-render, and state set function can actually accept a updater function as an argument, the solution is pure magic:

const Component = () => {
  // create some random state that we'll use to throw errors
  const [state, setState] = useState();

  const onClick = () => {
    try {
      // something bad happened
    } catch (e) {
      // trigger state update, with updater function as an argument
      setState(() => {
        // re-throw this error within the updater function
        // it will be triggered during state update
        throw e;
      })
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Full example here:

The final step here would be to abstract that hack away, so we don’t have to create random states in every component. We can go creative here, and make a hook that gives us an async errors thrower:

const useThrowAsyncError = () => {
  const [state, setState] = useState();

  return (error) => {
    setState(() => throw error)
  }
}
Enter fullscreen mode Exit fullscreen mode

And use it like this:

const Component = () => {
  const throwAsyncError = useThrowAsyncError();

  useEffect(() => {
    fetch('/bla').then().catch((e) => {
      // throw async error here!
      throwAsyncError(e)
    })
  })
}
Enter fullscreen mode Exit fullscreen mode

Or, we can create a wrapper for callbacks like this:

const useCallbackWithErrorHandling = (callback) => {
  const [state, setState] = useState();

  return (...args) => {
    try {
      callback(...args);
    } catch(e) {
      setState(() => throw e);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And use it like this:

const Component = () => {
  const onClick = () => {
    // do something dangerous here
  }

  const onClickWithErrorHandler = useCallbackWithErrorHandling(onClick);

  return <button onClick={onClickWithErrorHandler}>click me!</button>
}
Enter fullscreen mode Exit fullscreen mode

Or anything else that your heart desires and the app requires. No limits! And no errors will get away anymore.

Full example here:

Can I just use react-error-boundary instead?

For those of you, who hate re-inventing the wheel or just prefer libraries for already solved problems, there is a nice one that implements a flexible ErrorBoundary component and has a few useful utils similar to those described above: GitHub - bvaughn/react-error-boundary

Whether to use it or not is just a matter of personal preferences, coding style, and unique situations within your components.


That is all for today, hope from now on if something bad happens in your app, you’ll be able to deal with the situation with ease and elegance.

And remember:

  • try/catch blocks won't catch errors inside hooks like useEffect and inside any children components
  • ErrorBoundary can catch them, but it won’t catch errors in async code and event handlers
  • Nevertheless, you can make ErrorBoundary catch those, you just need to catch them with try/catch first and then re-throw them back into the React lifecycle

Live long and error-free! ✌🏼


Originally published at https://www.developerway.com. The website has more articles like this 😉

Subscribe to the newsletter, connect on LinkedIn or follow on Twitter to get notified as soon as the next article comes out.

💖 💪 🙅 🚩
adevnadia
Nadia Makarevich

Posted on February 23, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related