This page is a focused deep-dive under Error Propagation Strategies, which itself sits within the Frontend Error Boundary Architecture & Fundamentals topic area.
The Problem: Silent Intercept in a Nested Boundary Tree #
A parent ErrorBoundary returns null from its error-state render(), so the crash disappears. No fallback is shown, no telemetry fires, and React unmounts the subtree without complaint. The effect is indistinguishable from a deliberate empty render — users see a blank region, engineers see nothing in logs.
This failure mode is distinct from a boundary that shows the wrong fallback or logs to the wrong service. Swallowing means the exception is absorbed into state.hasError = true and the component tree is silently removed. The root cause is almost always one of three things: a missing fallback branch in render(), a componentDidCatch that only console.warns instead of forwarding to telemetry, or an intermediate library boundary that intercepts before your own boundary sees the error.
The diagram below shows how an error originating in a leaf component travels upward and can be absorbed at any boundary layer before reaching your observability pipeline.
Zero-to-Working: Depth-Aware Boundary with Re-Throw Policy #
The solution is a single ErrorBoundary class that accepts a depth prop and a shouldOwn predicate. When the predicate returns false, the boundary records telemetry and re-throws — passing the error to the next boundary up the tree rather than absorbing it.
// depth-aware-boundary.tsx
import React from 'react';
interface BoundaryProps {
depth: number;
// Return true if this boundary is the designated owner of the caught error.
// A false return causes the boundary to re-throw after logging.
shouldOwn?: (error: Error, depth: number) => boolean;
fallback: React.ReactNode;
onError?: (error: Error, info: React.ErrorInfo, depth: number) => void;
children: React.ReactNode;
}
interface BoundaryState {
error: Error | null;
}
export class DepthAwareBoundary extends React.Component<BoundaryProps, BoundaryState> {
static defaultProps = { shouldOwn: () => true };
state: BoundaryState = { error: null };
// getDerivedStateFromError sets state; it cannot re-throw.
// Keep it minimal — no side-effects here.
static getDerivedStateFromError(error: Error): BoundaryState {
return { error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
const { depth, shouldOwn, onError } = this.props;
// Always run telemetry before deciding whether to re-throw.
onError?.(error, info, depth);
// Attach component stack to error for upstream boundaries.
(error as any).__componentStack = info.componentStack;
(error as any).__capturedAtDepth = depth;
// If this boundary is not the designated owner, reset own state
// and re-throw so the parent boundary receives the error.
if (!shouldOwn!(error, depth)) {
// Reset so this boundary does not show its fallback.
this.setState({ error: null });
throw error;
}
}
render() {
if (this.state.error) {
return this.props.fallback;
}
return this.props.children;
}
}
Usage in a two-level boundary tree:
// app-shell.tsx — wire up the hierarchy
import { DepthAwareBoundary } from './depth-aware-boundary';
import { reportToTelemetry } from './telemetry';
function AppShell({ children }: { children: React.ReactNode }) {
return (
// Depth 0: root boundary always owns — it is the last resort.
<DepthAwareBoundary
depth={0}
fallback={<RootErrorFallback />}
onError={(err, info, d) => reportToTelemetry({ err, info, depth: d, level: 'root' })}
>
{/* Depth 1: widget boundary only owns errors tagged to depth >= 1. */}
<DepthAwareBoundary
depth={1}
shouldOwn={(err) => (err as any).__capturedAtDepth !== 0}
fallback={<WidgetFallback />}
onError={(err, info, d) => reportToTelemetry({ err, info, depth: d, level: 'widget' })}
>
{children}
</DepthAwareBoundary>
</DepthAwareBoundary>
);
}
Step-by-Step Walkthrough #
-
getDerivedStateFromErrorstays minimal. It only setsstate.error; no logging, no predicate evaluation. This is intentional — the static method cannot accessthis.props, so ownership decisions must live incomponentDidCatch. -
componentDidCatchruns telemetry unconditionally.onErrorfires regardless of ownership. This ensures every boundary layer in the tree reports the error before deciding whether to pass it upward — no crash escapes your observability pipeline even if the owner boundary re-throws. -
Stack metadata travels with the error. Writing
__componentStackand__capturedAtDepthdirectly onto the error object means any upstream boundary orwindow.onerrorhandler receives the full context without needing a separate event bus. -
this.setState({ error: null })before re-throwing. If the boundary leaveserrorset in state, it renders its own fallback and re-throws, producing a double-fallback artifact. Resetting state first ensures the boundary stays transparent. -
The re-throw happens synchronously inside
componentDidCatch. React’s reconciler catches the re-throw and walks up to the next boundary. Do not defer the re-throw withsetTimeoutor a Promise — that breaks the boundary’s synchronous error-interception model. -
Fallback components are guarded. Both
<RootErrorFallback />and<WidgetFallback />are wrapped in a minimal static boundary (not shown above) that renders only a plain<p>element — so a bug in the fallback itself cannot trigger infinite boundary recursion.
Edge Cases #
| Scenario | Symptom | Mitigation |
|---|---|---|
| Third-party library boundary sits between your child and parent boundaries | Your onError never fires; the library boundary absorbs the error without forwarding |
Audit the component tree with React DevTools; hoist your boundary above the library wrapper |
| React 18 concurrent mode deferred render | componentDidCatch fires out-of-order during interrupted renders |
Pin to a single React root with createRoot; avoid mixing legacy render() and createRoot in the same tree |
Re-throw inside an async useEffect in a child component |
The error bypasses all boundaries and surfaces only in window.onerror |
Route useEffect errors through the custom-hooks-for-async-error-catching pattern |
| SSR hydration mismatch triggers boundary on the client | Boundary activates during hydration, masking the original server/client mismatch | Investigate hydration mismatch state recovery before adding more boundary layers |
Verification Steps #
React DevTools: Open the Components panel, filter by DepthAwareBoundary. Trigger the error (temporarily throw inside a child). Confirm only the intended depth shows hasError: true in the state panel; all other boundary instances should remain in their normal state.
Console pattern: The onError callback at depth 1 should log { depth: 1, level: 'widget' } and the root callback should not fire. If both callbacks log, the re-throw is not resetting state before throwing.
Vitest assertion:
// depth-aware-boundary.test.tsx
import { render, screen } from '@testing-library/react';
import { DepthAwareBoundary } from './depth-aware-boundary';
function Bomb(): never {
throw new Error('test-crash');
}
test('depth-1 boundary with shouldOwn:false passes error to depth-0', () => {
const rootOnError = vi.fn();
const widgetOnError = vi.fn();
render(
<DepthAwareBoundary depth={0} fallback={<p>root fallback</p>} onError={rootOnError}>
<DepthAwareBoundary
depth={1}
shouldOwn={() => false} // always defer to parent
fallback={<p>widget fallback</p>}
onError={widgetOnError}
>
<Bomb />
</DepthAwareBoundary>
</DepthAwareBoundary>
);
// Root fallback renders, not widget fallback.
expect(screen.getByText('root fallback')).toBeInTheDocument();
expect(screen.queryByText('widget fallback')).toBeNull();
// Both onError callbacks fired — telemetry is uninterrupted.
expect(widgetOnError).toHaveBeenCalledWith(
expect.any(Error), expect.any(Object), 1
);
expect(rootOnError).toHaveBeenCalledWith(
expect.any(Error), expect.any(Object), 0
);
});
Frequently Asked Questions #
Why does my nested boundary catch the error but render nothing?
getDerivedStateFromError sets state.hasError = true, but the render() method returns null in the error branch instead of a fallback element. Check the boundary’s render method — the error-state branch must explicitly return a React node, not null and not undefined.
Can I force an error to skip a specific boundary?
Not via a React API, but re-throwing inside componentDidCatch (after resetting state to clear the error-state flag) causes React to walk the error up to the next ancestor boundary. The re-throw must be synchronous — wrapping it in Promise.resolve().then(() => throw error) removes it from React’s synchronous reconciliation pass.
How do third-party library boundaries cause swallowing?
Design system, analytics, and feature-flag SDK components often wrap their subtrees in undocumented boundaries. These boundaries absorb errors before your own boundaries see them, and their componentDidCatch typically only logs to the vendor’s own endpoint. Use React DevTools’ Components panel, filter by ErrorBoundary, and confirm which boundary instance actually receives the error at runtime.
Related #
- Error Propagation Strategies — parent topic covering the full range of propagation patterns including re-throw, forwarding, and async interception
- Best Practices for Global vs Local Error Boundaries in SPAs — granularity decisions that determine which boundary layer should own a given error class
- State Reset and Cleanup Protocols — deterministic state rollback patterns to run before or after re-throwing errors up the boundary tree
- Custom Hooks for Async Error Catching — handling the async errors that bypass all boundary layers entirely