DEV Community

Cover image for Error Boundaries in React, how It's made?
Artem Malko
Artem Malko

Posted on • Edited on

Error Boundaries in React, how It's made?

Hello everyone from Siberia ❄!

TLDR This post is not about how to use Error Boundaries, but why we have to use it in a React app.

Let's imagine, you're writing a reviews React app. When a user opens a reviews list, clicks on the “Write a review” button (a “type your email“ popup appears), but the code intended to verify the email has a bug! As a result, there is a white screen. React can't render anything due to the bug, somewhere in the popup.

The first thought is “we could keep the list on the screen”! There weren't any errors in the list. So, you have to use Error Boundaries to catch and handle any error in render-phase in React, to prevent its propagation. However, the main question is — why only that way? This post was made for the most curious developers. Let's find out.

try/catch is on the way to help

Ok, let's start with something simple. If somebody will ask you, how to catch and handle any error in JavaScript, you'll answer without no doubts, that it's possible with try/catch block:

try {
 throw new Error('Hello, World! My name is error!');
} catch (error) {
 console.error(error);
}
Enter fullscreen mode Exit fullscreen mode

Let's run the code in a browser's console. We will see a message and a callstack of the error. Quite a simple concept, known from 1995. Everything is understandable here.

Now, we'll talk about React. There is one common idea behind it. We can say, React is a function, which takes any data as a parameter and returns its visual representation. Something like this:

function React(data) {
  return UI;
}

const UI = React({ name: 'John' });
Enter fullscreen mode Exit fullscreen mode

Yeah, I know, it looks abstract a little, but it's enough right now. Looks like we can apply the same approach for error handling here, which is used everywhere in a JavaScript code:

try {
  const UI = React({ name: 'John' });
} catch (error) {
  console.error(error);
}
Enter fullscreen mode Exit fullscreen mode

Everything looks fine. Let's try to implement it in a real code.

Wrap the world with try/catch

Each React app has an “entry point”. I'm talking about ReactDOM.render. This method allows us to render our app into a specific DOM-Node:

ReactDOM.render(
  <App />,
  document.getElementById("app")
);
Enter fullscreen mode Exit fullscreen mode

An old fashioned synchronous render of <App /> and all of its components. Hm, the best place to wrap our app with try/catch:

try {
 ReactDOM.render(
  <App />,
  document.getElementById("app")
 );
} catch (error) {
 console.error("React render error: ", error);
}
Enter fullscreen mode Exit fullscreen mode

All errors which will be thrown during the first render will be handled by that try/catch.

But, if the error will be thrown during a state change somewhere in a component inside, that try/catch will be useless. ReactDOM.render will be executed, its work has been done — the first render of <App /> into the DOM. All other things is not about ReactDOM.render.

There is a demo, where you can try such approach. AppWithImmediateError.js contains a component, which throws an error during the first render. On the other hand, AppWithDeferredError.js contains a component, which throws an error while inner state is changing. As you can see, our version of “global try/catch” will handle the error from AppWithImmediateError.js only. Check out a console.

However, it doesn't look like a popular approach. That was just an illustration of the first render. There will be some strange examples lately. But they will be quite useful for us, cause they will reveal some features from React, its internals.

By the way, new ReactDom's render methods from React 18 won't be synchronous anymore. So, our approach won't work, even for the first render.

try/catch inside a component

“Global try/catch” is an interesting idea, but it doesn't work. So, the next concept is to use try/catch inside each component. And there is no any taboo to do it. Let's forget about declarative programming, pure functions and etc. JSX syntax allows us to use try/catch inside render:

// We can use a class here too
// Just wrap an inner of the render method
const App = () => {
 try {
  return (
   <div>
    <ChildWithError />
   </div>
  );
 } catch (error) {
  console.error('App error handler: ', error);  
  return <FallbackUI/>;
 }
}
Enter fullscreen mode Exit fullscreen mode

And there is another demo where you can find an implementation of a such concept. Just open it and click a button “Increase value”. When a value inside <ChildWithError/> will be 4, this component will throw an error inside render. But there won't be any message in console, no any fallback UI. Wait, WAT? We all know, that:

<div>
 <ChildWithError />
</div>
Enter fullscreen mode Exit fullscreen mode

will become

React.createElement(
  'div', 
  null, 
  React.createElement(ChildWithError, null)
)
Enter fullscreen mode Exit fullscreen mode

after babel/typescript/something else processing. It means, all of our JSX will be transformed to React.createElement execution. But it means, try/catch has to handle all of the errors. What is wrong? Does React can stop JS-function execution?

What is going on inside render?

If you will look closely, you will see, there is no render execution of ChildWithError component inside React.createElement(ChildWithError, null). But wait, what is a result of React.createElement execution? If you want to see the source code, there is a link. In general, the next object will be returned:

// The source: https://github.com/facebook/react/blob/main/packages/react/src/ReactElement.js#L148
const element = {
 // This tag allows us to uniquely identify this as a React Element
 $$typeof: REACT_ELEMENT_TYPE, // Built-in properties that belong on the element
 type: type,
 key: key,
 ref: ref,
 props: props, // Record the component responsible for creating this element.
 _owner: owner,
};
Enter fullscreen mode Exit fullscreen mode

So, there will be just some objects inside other objects. For our example we get an object, which describes <App />. There is an object, which describe <ChildWithError /> inside props.children of that <App />. You can see it by yourself, just try to console.log it.

There is no ChildWithError's render function execution. We've just created a scheme, a bunch of instructions for React. Render executes from parents to children. It looks like we talk to React: if <App /> is rendered, <ChildWithError /> intended to be rendered too, right inside that <App />.

This is the main idea of declarative views in React.

Now you can say, that we need to execute ChildWithError's render to create such object. And you are absolutely right! But ChildWithError's render function won't be executed inside <App />. I can say at the moment, React will call all render functions by itself, somewhere in its own context. I'll describe this idea lately.

There is an analogy: componentDidUpdate is executed via React after render. Or another one:

try {
 Promise.resolve().then(() => {
  throw new Error('wow!');
 });
} catch (error) {
 console.log('Error from catch: ', error);
}
Enter fullscreen mode Exit fullscreen mode

That error from a promise won't be catched inside try/catch cause it will be thrown in a microtasks queue. Catch is from a sync callstack queue.

By the way, you can check it by yourself. Just replace <ChildWithError /> to {ChildWithError()} inside <App />. It means, we will call ChildWithError's render by ourselves. And voila! You will see an error message in the console and the fallback UI in the browser!

Voila

And why not to write like this everywhere? Just call all of the render functions? It is supposed to work faster, we do not need to wait, when React will render all of the components.

If you have such thoughts, you have to read a brilliant Dan Abaramov's article — React as a UI Runtime. It might help you understand the React programming model in more depth. It is strongly recommended to check out Inversion of Control and Lazy Evaluation from that article.

Fun fact, sometimes ago manual component execution was recommended as a pattern how to increase performance of any React app. There is an example, when such approach will broke our app:

function Counter() {
  const [count, setCount] = React.useState(0)
  const increment = () => setCount(c => c + 1)
  return <button onClick={increment}>{count}</button>
}

function App() {
  const [items, setItems] = React.useState([])
  const addItem = () => setItems(i => [...i, {id: i.length}])
  return (
    <div>
      <button onClick={addItem}>Add Item</button>
      <div>{items.map(Counter)}</div>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

There is a demo with the code above. After the first click to AddItem button where will be an error with hooks order. This example is taken from a Kent C. Dodds' article Don't call a React function component.

Let's go back to the error handling in a React app. As we understand try/catch inside render() {} is not enough. We have to handle errors in all lifecycle-methods too in case of usage of class components. It doesn't look like a smart idea. So, what is the conclusion? Yes, we have to use only functional components, cause it's much more easy to use try/catch there =)

The "real life" example

I have a little demo with Error Boundaries and classic try/catch.

What do we have here: functional component <App />, which has internal state (via useState). The value of that state is shared via React.context. <App /> renders <Child />. <Child /> is wrapped with HOC memo. <Child /> renders <GrandChild />.

The most interesting thing here is try/catch inside <Child />. In my idea, this try catch has to handle all errors from <GrandChild />. And <GrandChild /> has a specific logic to throw an error, when the value from the context will be more that 3. There is a scheme:

The scheme of the demo

I have getDerivedStateFromError and componentDidCatch inside <App />. It means, <App /> is used as Error Boundary.

Let's click a button. After the first click <App /> and <GrandChild /> will be rerendered. <App /> — cause of the state change, <GrandChild /> — cause of the context value change. Looks like there is no any <Child /> between <App /> and <GrandChild />. It's because of HOC memo. Let's highlight all rerendered components:

The first render result

So, if we will continue to increase the counter from <App /> two times more, an error will be thrown inside <GrandChild />. But <Child /> do not know about anything around with its try/catch.

This demo is just a simple model that illustrated, that React decides what to render and when.

By the way, we've just seen, how to use Error Boundaries) But I strongly recommend you to read the docs. Moreover, it doesn't mean, what try/catch is totally useless. We have to use it for:

  • Event handlers
  • Async code
  • Errors thrown in the error boundary itself

Ok, the next part is the most interesting — let's find out, how Error Boundaries works. Is it a special try/catch?

React's try/catch

Say hello to magic React Fiber. This is a name of an architecture and a name of internal entity from React itself. By the way, you could see it in React docs, after 16th version has been released.

If you will log the result of React.createElement execution, you will see quite much information (there is just a part of it):

React component instance internals

What does it mean for us? In addition to data about a component's type, props and etc, there is an info from a Fiber Node. This Node is connected with React component, and it has a lot of useful information (for React) about the component: new and old props, what effect should be executed, should the component be rerendered right now and etc. You can get more info about Fiber- architecture on inDepth.dev or acdlite's (React-core team member) article React Fiber Architecture.

Ok, React knows an internal data of each component. It means, React knows what to do in a case of any error, which could be thrown during the render phase. React can stop the render phase for the current tree (not a component!). After that React tries to find the closest parent of the component with the error, which has defined getDerivedStateFromError or componentDidCatch method (one of them). And it's not a big deal, cause every Fiber-Node has a link to its parent Fiber-Node. There is the source code of how it works.

The render process in React is represented with a quite simple code — workLoop. As you can see, there is no magic, workLoop is wrapped with try/catch. If any error is catched, React will try to find a component with Error Boundary. If such component is found, it means, that React can throw away just only that tree, till the boundary.

If we will try to imagine a work with React as a dialog with a real person, it will look like this (“Explain Like I'm 5” style)

Hi. My name is React.
Thanks for the instructions from JSX about what to render. 
You can have a coffee, while I am doing my job.

try {
  *React is working*
} catch (error) {
  Oops, a new error.

  Ok, I will try to find a parent of the component 
  with that error. 
  Maybe that parent can do something with it.
  All other work will be saved!
}
Enter fullscreen mode Exit fullscreen mode

The message

I think, such questions, strange experiments and etc can help you to dive deep into a technology, which is used by you. It can help you to truly understand, how to work with it. Maybe you will find something new for yourself. I am absolutely sure that such a journey always pays off.

A list of useful links

Top comments (0)