How does ErrorBoundary work internally in React?

1. ErrorBoundary is a declarative try…catch for React fiber tree

jsx
<ErrorBoundary fallback={<p>Something went wrong</p>}>
<Profile />
</ErrorBoundary>
jsx
<ErrorBoundary fallback={<p>Something went wrong</p>}>
<Profile />
</ErrorBoundary>

If Profile throws in rendering, ErrorBoundary will try to render the fallback, as explained in How does React traverse Fiber tree internally, the rendering order of fiber nodes will be like below.

Any Class Component that implements static getDerivedStateFromError(error) is an ErrorBoundary.

2. How does ErrorBoundary work internally?

2.1 When an error is thrown, closest ErrorBoundary is marked with flag: ShouldCapture and scheduled with state update.

function renderRootSync(root: FiberRoot, lanes: Lanes) {
...
do {
try {
workLoopSync();
break;
} catch (thrownValue) {
handleError(root, thrownValue);
-----------
}
} while (true);
...
return workInProgressRootExitStatus;
}
function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
...
do {
try {
workLoopConcurrent();
break;
} catch (thrownValue) {
handleError(root, thrownValue);
-----------
}
} while (true);
...
return workInProgressRootExitStatus;
}
function renderRootSync(root: FiberRoot, lanes: Lanes) {
...
do {
try {
workLoopSync();
break;
} catch (thrownValue) {
handleError(root, thrownValue);
-----------
}
} while (true);
...
return workInProgressRootExitStatus;
}
function renderRootConcurrent(root: FiberRoot, lanes: Lanes) {
...
do {
try {
workLoopConcurrent();
break;
} catch (thrownValue) {
handleError(root, thrownValue);
-----------
}
} while (true);
...
return workInProgressRootExitStatus;
}

Either for Sync mode or Concurrent mode, a big try...catch is applied for the work loop, and handleError() handles the thrown values.

function handleError(root, thrownValue): void {
do {
let erroredWork = workInProgress;
try {
...
throwException(
--------------
root,
erroredWork.return,
erroredWork,
thrownValue,
workInProgressRootRenderLanes,
);
completeUnitOfWork(erroredWork);
------------------
} catch (yetAnotherThrownValue) {
...
}
// Return to the normal work loop.
return;
} while (true);
}
function handleError(root, thrownValue): void {
do {
let erroredWork = workInProgress;
try {
...
throwException(
--------------
root,
erroredWork.return,
erroredWork,
thrownValue,
workInProgressRootRenderLanes,
);
completeUnitOfWork(erroredWork);
------------------
} catch (yetAnotherThrownValue) {
...
}
// Return to the normal work loop.
return;
} while (true);
}

handleError() internally throws and start completing the work on the fiber node. As explained in How does React traverse Fiber tree internally?, since error is thrown we don’t need to go down to children any furthur, we should start complete() phase.

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

This InComplete flag is imporant for completeUnitOfWork()

if (
value !== null &&
typeof value === 'object' &&
typeof value.then === 'function'
) {

this branch is for Suspense

...
} else {

this branch is for normal error boundary

...
value = createCapturedValueAtFiber(value, sourceFiber);
renderDidError(value);
let workInProgress: Fiber = returnFiber;
do {

This do...while loop searches for the closest error boundary along the path to root

switch (workInProgress.tag) {
case HostRoot: {
...
}
case ClassComponent:
--------------
// Capture and retry
const errorInfo = value;
const ctor = workInProgress.type;
const instance = workInProgress.stateNode;
if (
(workInProgress.flags & DidCapture) === NoFlags &&
(typeof ctor.getDerivedStateFromError === 'function' ||
~~~~~~~~~~~~~~~~~~~~~~~~

We found a boundary!

(instance !== null &&
typeof instance.componentDidCatch === 'function' &&
!isAlreadyFailedLegacyErrorBoundary(instance)))
) {
workInProgress.flags |= ShouldCapture;
-------------

ShouldCapture will turn into DidCapture soon

const lane = pickArbitraryLane(rootRenderLanes);
workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
// Schedule the error boundary to re-render using updated state
const update = createClassErrorUpdate(

This error boundary needs to handle error now, so schedules an update on it

workInProgress,
errorInfo,
lane,
);
enqueueCapturedUpdate(workInProgress, update);
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
return;
}
break;
default:
break;
}
// $FlowFixMe[incompatible-type] we bail out when we get a null
workInProgress = workInProgress.return;
} while (workInProgress !== null);
}
function throwException(
root: FiberRoot,
returnFiber: Fiber,
sourceFiber: Fiber,
value: mixed,
rootRenderLanes: Lanes,
): void {
// The source fiber did not complete.
sourceFiber.flags |= Incomplete;

This InComplete flag is imporant for completeUnitOfWork()

if (
value !== null &&
typeof value === 'object' &&
typeof value.then === 'function'
) {

this branch is for Suspense

...
} else {

this branch is for normal error boundary

...
value = createCapturedValueAtFiber(value, sourceFiber);
renderDidError(value);
let workInProgress: Fiber = returnFiber;
do {

This do...while loop searches for the closest error boundary along the path to root

switch (workInProgress.tag) {
case HostRoot: {
...
}
case ClassComponent:
--------------
// Capture and retry
const errorInfo = value;
const ctor = workInProgress.type;
const instance = workInProgress.stateNode;
if (
(workInProgress.flags & DidCapture) === NoFlags &&
(typeof ctor.getDerivedStateFromError === 'function' ||
~~~~~~~~~~~~~~~~~~~~~~~~

We found a boundary!

(instance !== null &&
typeof instance.componentDidCatch === 'function' &&
!isAlreadyFailedLegacyErrorBoundary(instance)))
) {
workInProgress.flags |= ShouldCapture;
-------------

ShouldCapture will turn into DidCapture soon

const lane = pickArbitraryLane(rootRenderLanes);
workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
// Schedule the error boundary to re-render using updated state
const update = createClassErrorUpdate(

This error boundary needs to handle error now, so schedules an update on it

workInProgress,
errorInfo,
lane,
);
enqueueCapturedUpdate(workInProgress, update);
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
return;
}
break;
default:
break;
}
// $FlowFixMe[incompatible-type] we bail out when we get a null
workInProgress = workInProgress.return;
} while (workInProgress !== null);
}

Inside the update is where getDerivedStateFromError() is called actually.

function createClassErrorUpdate(
fiber: Fiber,
errorInfo: CapturedValue<mixed>,
lane: Lane,
): Update<mixed> {
const update = createUpdate(lane);
update.tag = CaptureUpdate;
const getDerivedStateFromError = fiber.type.getDerivedStateFromError;
if (typeof getDerivedStateFromError === 'function') {
const error = errorInfo.value;
update.payload = () => {
return getDerivedStateFromError(error);
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
};
update.callback = () => {
logCapturedError(fiber, errorInfo);
};
}
function createClassErrorUpdate(
fiber: Fiber,
errorInfo: CapturedValue<mixed>,
lane: Lane,
): Update<mixed> {
const update = createUpdate(lane);
update.tag = CaptureUpdate;
const getDerivedStateFromError = fiber.type.getDerivedStateFromError;
if (typeof getDerivedStateFromError === 'function') {
const error = errorInfo.value;
update.payload = () => {
return getDerivedStateFromError(error);
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
};
update.callback = () => {
logCapturedError(fiber, errorInfo);
};
}

And enqueueCapturedUpdate() sets the update to updateQueue of the errored fiber, then processUpdateQueue() inside mountClassInstance() and updateClassInstance(), makes sure they are processed for next render.

These are some details for Class Components, which is old-age stuff, we won’t cover them in details here, just remmeber that after this, the closest error boundary will have a new state reflecting the error for its next render.

2.2 flag:ShouldCapture is changed to flag:DidCapture during unwinding

In How does Context work internally in React?, I’ve mentioned that React runtime stores a lot of info along the path, which means cleanup work for each fiber is critical to not mess things up.

In throwException() we searched for the closest ErrorBoundary, but we haven’t move the current cursor of fiber tree to that boundary yet. Unwinding is such process - go backward to the closest error boundary and try re-render from there.

And unwinding happens inside completeUnitOfWork(), which we mentioned earlier in this post.

function completeUnitOfWork(unitOfWork: Fiber): void {
let completedWork = unitOfWork;
do {
// The current, flushed, state of this fiber is the alternate. Ideally
// nothing should rely on this, but relying on it here means that we don't
// need an additional field on the work in progress.
const current = completedWork.alternate;
const returnFiber = completedWork.return;
// Check if the work completed or if something threw.
if ((completedWork.flags & Incomplete) === NoFlags) {

See why Incomplete flag is important here

Branch below is normal branch

let next;
if (
!enableProfilerTimer ||
(completedWork.mode & ProfileMode) === NoMode
) {
next = completeWork(current, completedWork, subtreeRenderLanes);
} else {
startProfilerTimer(completedWork);
next = completeWork(current, completedWork, subtreeRenderLanes);
// Update render duration assuming we didn't error.
stopProfilerTimerIfRunningAndRecordDelta(completedWork, false);
}
resetCurrentDebugFiberInDEV();
if (next !== null) {
// Completing this fiber spawned new work. Work on that next.
workInProgress = next;
return;
}
} else {

Here is Incomplete branch, where we need to unwind

// 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);
// Because this fiber did not complete, don't reset its lanes.
if (next !== null) {

This branch shows us that if completeWork() returns a fiber node,

React will re-render from that node, rather than continue completeWork() on parent

// If completing this work spawned new work, do that next. We'll come
// back here again.
// Since we're restarting, remove anything that is not a host effect
// from the effect tag.
next.flags &= HostEffectMask;
workInProgress = next;
return;
}
if (returnFiber !== null) {
// Mark the parent fiber as incomplete and clear its subtree flags.
returnFiber.flags |= Incomplete;

All ancester nodes of InComplete fiber nodes are all InComplete.

This makes sure closest ErrorBoundary is InComplete,

so that unwindWork() will be called against the boundary

returnFiber.subtreeFlags = NoFlags;
returnFiber.deletions = null;
} else {
// We've unwound all the way to the root.
workInProgressRootExitStatus = RootDidNotComplete;
workInProgress = null;
return;
}
}
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
// If there is more work to do in this returnFiber, do that next.
workInProgress = siblingFiber;
return;
}
// Otherwise, return to the parent
completedWork = returnFiber;
// Update the next thing we're working on in case something throws.
workInProgress = completedWork;
} while (completedWork !== null);
// We've reached the root.
if (workInProgressRootExitStatus === RootInProgress) {
workInProgressRootExitStatus = RootCompleted;
}
}
function completeUnitOfWork(unitOfWork: Fiber): void {
let completedWork = unitOfWork;
do {
// The current, flushed, state of this fiber is the alternate. Ideally
// nothing should rely on this, but relying on it here means that we don't
// need an additional field on the work in progress.
const current = completedWork.alternate;
const returnFiber = completedWork.return;
// Check if the work completed or if something threw.
if ((completedWork.flags & Incomplete) === NoFlags) {

See why Incomplete flag is important here

Branch below is normal branch

let next;
if (
!enableProfilerTimer ||
(completedWork.mode & ProfileMode) === NoMode
) {
next = completeWork(current, completedWork, subtreeRenderLanes);
} else {
startProfilerTimer(completedWork);
next = completeWork(current, completedWork, subtreeRenderLanes);
// Update render duration assuming we didn't error.
stopProfilerTimerIfRunningAndRecordDelta(completedWork, false);
}
resetCurrentDebugFiberInDEV();
if (next !== null) {
// Completing this fiber spawned new work. Work on that next.
workInProgress = next;
return;
}
} else {

Here is Incomplete branch, where we need to unwind

// 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);
// Because this fiber did not complete, don't reset its lanes.
if (next !== null) {

This branch shows us that if completeWork() returns a fiber node,

React will re-render from that node, rather than continue completeWork() on parent

// If completing this work spawned new work, do that next. We'll come
// back here again.
// Since we're restarting, remove anything that is not a host effect
// from the effect tag.
next.flags &= HostEffectMask;
workInProgress = next;
return;
}
if (returnFiber !== null) {
// Mark the parent fiber as incomplete and clear its subtree flags.
returnFiber.flags |= Incomplete;

All ancester nodes of InComplete fiber nodes are all InComplete.

This makes sure closest ErrorBoundary is InComplete,

so that unwindWork() will be called against the boundary

returnFiber.subtreeFlags = NoFlags;
returnFiber.deletions = null;
} else {
// We've unwound all the way to the root.
workInProgressRootExitStatus = RootDidNotComplete;
workInProgress = null;
return;
}
}
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
// If there is more work to do in this returnFiber, do that next.
workInProgress = siblingFiber;
return;
}
// Otherwise, return to the parent
completedWork = returnFiber;
// Update the next thing we're working on in case something throws.
workInProgress = completedWork;
} while (completedWork !== null);
// We've reached the root.
if (workInProgressRootExitStatus === RootInProgress) {
workInProgressRootExitStatus = RootCompleted;
}
}

And in unwindWork(), flag DidCapture comes into play.

function unwindWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
) {
// Note: This intentionally doesn't check if we're hydrating because comparing
// to the current tree provider fiber is just as fast and less error-prone.
// Ideally we would have a special version of the work loop only
// for hydration.
popTreeContext(workInProgress);
switch (workInProgress.tag) {
case ClassComponent: {
const Component = workInProgress.type;
if (isLegacyContextProvider(Component)) {
popLegacyContext(workInProgress);
}
const flags = workInProgress.flags;
if (flags & ShouldCapture) {
workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

See that ShouldCapture is turned into DidCapture

return workInProgress;

As explained before this, this returning means React re-renders from this fiber,

which is the closest boundary

}
return null;
}
...
}
}
function unwindWork(
current: Fiber | null,
workInProgress: Fiber,
renderLanes: Lanes,
) {
// Note: This intentionally doesn't check if we're hydrating because comparing
// to the current tree provider fiber is just as fast and less error-prone.
// Ideally we would have a special version of the work loop only
// for hydration.
popTreeContext(workInProgress);
switch (workInProgress.tag) {
case ClassComponent: {
const Component = workInProgress.type;
if (isLegacyContextProvider(Component)) {
popLegacyContext(workInProgress);
}
const flags = workInProgress.flags;
if (flags & ShouldCapture) {
workInProgress.flags = (flags & ~ShouldCapture) | DidCapture;
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

See that ShouldCapture is turned into DidCapture

return workInProgress;

As explained before this, this returning means React re-renders from this fiber,

which is the closest boundary

}
return null;
}
...
}
}

2.3 flag:DidCapture in ErrorBoundary enforces re-rendering with new children

function finishClassComponent(
current: Fiber | null,
workInProgress: Fiber,
Component: any,
shouldUpdate: boolean,
hasContext: boolean,
renderLanes: Lanes,
) {
const didCaptureError = (workInProgress.flags & DidCapture) !== NoFlags;
~~~~~~~~~~
...
const instance = workInProgress.stateNode;
// Rerender
ReactCurrentOwner.current = workInProgress;
let nextChildren;
if (
didCaptureError &&
typeof Component.getDerivedStateFromError !== 'function'
) {
// If we captured an error, but getDerivedStateFromError is not defined,
// unmount all the children. componentDidCatch will schedule an update to
// re-render a fallback. This is temporary until we migrate everyone to
// the new API.
// TODO: Warn in a future release.
nextChildren = null;
} else {
nextChildren = instance.render();

For this time, render() will return different children,

because of the scheduled update created when error is thrown.

}
// React DevTools reads this flag.
workInProgress.flags |= PerformedWork;
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(

its name and the comments above it says everything

current,
workInProgress,
nextChildren,
renderLanes,
);
} else {
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
}
return workInProgress.child;

Continue reconciling by going deeper to the children

}
function finishClassComponent(
current: Fiber | null,
workInProgress: Fiber,
Component: any,
shouldUpdate: boolean,
hasContext: boolean,
renderLanes: Lanes,
) {
const didCaptureError = (workInProgress.flags & DidCapture) !== NoFlags;
~~~~~~~~~~
...
const instance = workInProgress.stateNode;
// Rerender
ReactCurrentOwner.current = workInProgress;
let nextChildren;
if (
didCaptureError &&
typeof Component.getDerivedStateFromError !== 'function'
) {
// If we captured an error, but getDerivedStateFromError is not defined,
// unmount all the children. componentDidCatch will schedule an update to
// re-render a fallback. This is temporary until we migrate everyone to
// the new API.
// TODO: Warn in a future release.
nextChildren = null;
} else {
nextChildren = instance.render();

For this time, render() will return different children,

because of the scheduled update created when error is thrown.

}
// React DevTools reads this flag.
workInProgress.flags |= PerformedWork;
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(

its name and the comments above it says everything

current,
workInProgress,
nextChildren,
renderLanes,
);
} else {
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
}
return workInProgress.child;

Continue reconciling by going deeper to the children

}

The code is pretty straightforward.

3. Summary

Now let’s summarize what we’ve learned with some diagrams.

How ErrorBoundary works internally

(1/19)

4. Coding Challenge

Try out following coding quiz to enhance what we’ve learned today.

Want to know more about how React works internally?
Check out my series - React Internals Deep Dive!

😳 Would you like to share my post to more people ?    

❮ Prev: Introducing QuickCode 75 - a companion app for Grind 75 / Blind 75

Next: React types in TypeScript