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
}
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!
}
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!
})
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} />
}
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
}
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
}
}, [])
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;
}
or even this:
const Component = () => {
try {
return <Child />
} catch(e) {
// still useless for catching errors inside Child component, won't be triggered
}
}
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);
}
}
We could, of course, just return the error screen here instead of setting state:
const Component = () => {
try {
doSomethingComplicated();
} catch(e) {
// this allowed
return <SomeErrorScreen />
}
}
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} />
}
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>
)
}
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;
}
}
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);
}
}
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;
}
And use it like this:
const Component = () => {
return (
<ErrorBoundary fallback={<>Oh no! Do something!</>}>
<SomeChildComponent />
<AnotherChildComponent />
</ErrorBoundary>
)
}
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>
)
}
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>
)
}
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>
)
}
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;
})
}
}
}
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)
}
}
And use it like this:
const Component = () => {
const throwAsyncError = useThrowAsyncError();
useEffect(() => {
fetch('/bla').then().catch((e) => {
// throw async error here!
throwAsyncError(e)
})
})
}
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);
}
}
}
And use it like this:
const Component = () => {
const onClick = () => {
// do something dangerous here
}
const onClickWithErrorHandler = useCallbackWithErrorHandling(onClick);
return <button onClick={onClickWithErrorHandler}>click me!</button>
}
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 likeuseEffect
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 withtry/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.