[DIP] React ErrorBoundary

Tech

While studying React Internals Deep Dive, I dug into the internal code behind the lecture on ErrorBoundary. I want to document what I learned in more depth about the error-handling parts I had wrestled with in my projects, along with how ErrorBoundary works.

Even when starting a new project with React, I tend to handle errors in much the same way, using familiar logic without much thought.
Going through this study made me revisit the error-handling logic in my projects, and if anyone happens to read this, I hope it helps even a little with learning or handling errors in React.

*
Please note that this is written based onReact v18.3.1.

react-errorboundary-1.png



ErrorBoundary Internals

I'll walk through the ErrorBoundary internals following the call stack I traced while debugging, inserting the functions called under various conditions along the way.


renderRootSync / renderRootConcurrent

When rendering begins and an error occurs during the process driven by workLoopSync, the thrown error information starts to be handled through handleError.

tsx
function renderRootSync(root: FiberRoot, lanes: Lanes) { ... do { try { workLoopSync(); break; } catch (thrownValue) { handleError(root, thrownValue); } } while (true); ... }


handleError

Inside handleError, the actual handling of the error state is carried out through throwException.

tsx
function handleError(root, thrownValue): void { do { ... throwException( root, erroredWork.return, erroredWork, thrownValue, workInProgressRootRenderLanes, ); completeUnitOfWork(erroredWork); } while(true); ... }


throwException

In throwException, an update for the error is created and added to the in-progress render queue. It then calls completeUnitWork, which will render the error update right away, so that it proceeds together within the ongoing render cycle.

Setting the flag (Incomplete) that indicates the Fiber node did not complete during the subsequent render cycle also happens here.

Among the logic below, the parts I consider important are createRootErrorUpdate, which creates the update for the error, and enqueueCapturedUpdate, which adds the created update to the queue.

tsx
function throwException( root: FiberRoot, returnFiber: Fiber, sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes, ) { ... const update = createRootErrorUpdate(workInProgress, errorInfo, lane); enqueueCapturedUpdate(workInProgress, update); ... }


createRootErrorUpdate

The update to be added to the render queue is created, with its callback and payload set. These are configured so that getDerivedStateFromError and componentDidCatch, defined in the ErrorBoundary class component lifecycle, are called.

tsx
function createClassErrorUpdate( fiber: Fiber, errorInfo: CapturedValue<mixed>, lane: Lane, ): Update<mixed> { ... update.payload = () => { return getDerivedStateFromError(error); }; ... update.callback = function callback() { this.componentDidCatch(error, { componentStack: stack !== null ? stack : '', }); ... } ... }


enqueueCapturedUpdate

The created update is added to the workInProgress update render queue, and completeUnitOfWork is called so that it proceeds together within the currently ongoing render cycle.

tsx
export function enqueueCapturedUpdate<State>( workInProgress: Fiber, capturedUpdate: Update<State>, ) { ... queue = { baseState: currentQueue.baseState, firstBaseUpdate: newFirst, lastBaseUpdate: newLast, shared: currentQueue.shared, effects: currentQueue.effects, }; workInProgress.updateQueue = queue; return; }


ErrorBoundary's Core Strategy?

While examining the internals of ErrorBoundary, there were parts I considered to be its core logic or strategy. One was unwinding, and the flag-based state management handled within it left a strong impression, so I want to document them separately.


Unwinding

When an error occurs during rendering, the process of traversing backward from the point (node) where the error occurred up to the nearest ErrorBoundary, and re-attempting rendering from that point, is called unwinding.

In throwException, which we covered in the internals section, the Incomplete flag is set to indicate that the Fiber node failed to complete its work. The Incomplete flag propagates to parent nodes, and it is set on every ancestor node until the nearest ErrorBoundary is reached.

tsx
function throwException( root: FiberRoot, returnFiber: Fiber, sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes, ) { // The source fiber did not complete. sourceFiber.flags |= Incomplete; ... }

Then, during the rendering process, the completeUnitOfWork function checks the Incomplete flag and performs the error-handling logic. If the Incomplete flag is set, unwindWork is invoked, and if the Fiber (ErrorBoundary) has the ShouldCapture flag, it is changed to the DidCapture flag.

tsx
function completeUnitOfWork(unitOfWork: Fiber): void { ... // Check if the work completed or if something threw. if ((completedWork.flags & Incomplete) === NoFlags) { ... } else { // This fiber did not complete because something threw. Pop values off // the stack without entering the complete phase. If this is a boundary, // capture values if possible. const next = unwindWork(current, completedWork, subtreeRenderLanes); .. } }

Afterward, the returned Fiber (ErrorBoundary) is re-rendered, and after checking the DidCapture flag, it unmounts all its child nodes and performs reconciliation with the new children (the fallback UI).

tsx
function finishClassComponent( current: Fiber | null, workInProgress: Fiber, Component: any, shouldUpdate: boolean, hasContext: boolean, renderLanes: Lanes, ) { ... if (current !== null && didCaptureError) { // If we're recovering from an error, reconcile without reusing any of // the existing children. Conceptually, the normal children and the children // that are shown on error are two different sets, so we shouldn't reuse // normal children even if their identities match. forceUnmountCurrentAndReconcile( current, workInProgress, nextChildren, renderLanes, ); } else { reconcileChildren(current, workInProgress, nextChildren, renderLanes); }


Flag-Based State Management

In practice, flag-based state management is used most crucially in the unwinding process. Here, I'll simply summarize what role each flag's state plays.

Incomplete

Indicates that the current Fiber node did not render successfully to completion; when rendering is interrupted by an error, it is set through throwException.

ShouldCapture

Indicates the state where an ErrorBoundary or Suspense should capture an error/suspense.

DidCapture

Indicates that an ErrorBoundary or Suspense has captured an error/suspense; after checking this flag, fallback UI rendering proceeds.



Commit Phase Error Handling

Looking at the logic of ErrorBoundary, we can see that exceptions occurring during the render update of Fiber nodes in the Render Phase are handled with a fallback UI. I was curious how it would be handled if the Render Phase completed without issue but an exception occurred in the Commit Phase, so I looked into error situations in the Commit Phase.


In the Commit Phase, commitRoot performs DOM application and effect processing for Fiber nodes. Exceptions that arise during the various steps of this process are handled through captureCommitPhaseError, which schedules the rendering of the error update.

tsx
export function captureCommitPhaseError( sourceFiber: Fiber, nearestMountedAncestor: Fiber | null, error: mixed, ) { ... const update = createClassErrorUpdate( fiber, errorInfo, (SyncLane: Lane), ); const root = enqueueUpdate(fiber, update, (SyncLane: Lane)); ... }

Unlike a typical ErrorBoundary, the notable point was that the new state to be updated is scheduled with enqueueUpdate. In the Render Phase, ErrorBoundary handling adds the update to the queue via enqueueCapturedUpdate so it proceeds together within the currently ongoing render cycle. In the Commit Phase, however, handling schedules the update with enqueueUpdate so that it proceeds in the next render.



Error Render Scheduling?

Generally, ErrorBoundary handling does not schedule a new render to process the error.

Error rendering is handled simply by adding the error update information to the queue of the in-progress WorkInProgress Fiber node, so that it proceeds together within the ongoing render cycle.


enqueueUpdate

In a React project, ordinary state updates such as setState, useState, forceUpdate, and render schedule the Fiber node's update to drive rendering.

tsx
const root = enqueueUpdate(fiber, update, lane); if (root !== null) { scheduleUpdateOnFiber(root, fiber, lane, eventTime); // schedule a new render entangleTransitions(root, fiber, lane); }


enqueueCapturedUpdate

When an error occurs during rendering, the ErrorBoundary captures the error, and in the throwException function the error update is added to the queue of the WorkInProgress Fiber node that is currently rendering.

tsx
function throwException( root: FiberRoot, returnFiber: Fiber, sourceFiber: Fiber, value: mixed, rootRenderLanes: Lanes, ) { ... enqueueCapturedUpdate(workInProgress, update); ... }


In Conclusion

By examining the internals of ErrorBoundary directly, questions that had previously been unclear were resolved. I'm not sure how other projects handle it, but I dealt with errors occurring inside React components by dividing them into handling via ErrorBoundary and asynchronous error handling. Once I understood where and how errors are caught inside React, I could understand why the patterns I had been using without much thought are necessary. Based on this understanding, I feel I'll now be able to write more meaningful code and clearly identify improvements when using React.

react-errorboundary-2.png

“You can’t connect the dots looking forward; you can only connect them looking backwards.” — Steve Jobs