This page refines the bubbling model from Error Propagation Strategies with the case where the nearest boundary is deliberately the wrong place to stop.
The exact problem #
A comments widget on an article page catches an error and renders “Comments are unavailable”. Reasonable — except the underlying error was SessionExpiredError. The user’s token is invalid, so nothing on the page will work: the like button silently fails, the follow action 401s, and the only visible symptom is a small message about comments.
The nearest boundary is the right default. It is the wrong answer when the failure invalidates assumptions far beyond that subtree. What is needed is a boundary that can decide “this is not mine” and pass it up — without losing the attribution that says the comments widget was where it surfaced.
Zero-to-working escalation #
Start with a typed severity so the decision is data, not a string match.
// severity.ts
export type Severity = 'local' | 'route' | 'session';
export class AppError extends Error {
readonly severity: Severity;
readonly code: string;
constructor(code: string, message: string, severity: Severity, options?: ErrorOptions) {
super(message, options);
this.name = 'AppError';
this.code = code;
this.severity = severity;
}
}
export const SessionExpired = () =>
new AppError('session.expired', 'Session is no longer valid', 'session');
export const ConfigMissing = (key: string) =>
new AppError('config.missing', `Runtime config missing: ${key}`, 'session');
/** Non-enumerable bookkeeping so it never lands in a JSON payload. */
export function markReported(err: unknown): void {
if (err && typeof err === 'object') {
Object.defineProperty(err, '__reported', { value: true, enumerable: false, configurable: true });
}
}
export function wasReported(err: unknown): boolean {
return Boolean(err && typeof err === 'object' && '__reported' in err);
}
export function bumpEscalation(err: unknown): number {
if (!err || typeof err !== 'object') return 99;
const current = ('__escalations' in err ? Number((err as { __escalations: number }).__escalations) : 0) + 1;
Object.defineProperty(err, '__escalations', { value: current, enumerable: false, configurable: true });
return current;
}
The boundary then decides between handling and escalating, and escalates by rethrowing during render:
// ScopedBoundary.tsx
interface State { error: Error | null; escalate: boolean }
export class ScopedBoundary extends React.Component<Props, State> {
state: State = { error: null, escalate: false };
static getDerivedStateFromError(error: Error): State {
const severity = error instanceof AppError ? error.severity : 'local';
// 'local' stops here. Anything wider belongs to a boundary above us,
// unless we have already bounced it too many times.
const escalate = severity !== 'local' && bumpEscalation(error) <= 2;
return { error, escalate };
}
componentDidCatch(error: Error, info: React.ErrorInfo): void {
if (wasReported(error)) return; // reported once, at the first catch
markReported(error);
reportCrash({
scope: this.props.scope, // the ORIGINAL scope, preserved
severity: error instanceof AppError ? error.severity : 'local',
message: error.message,
componentStack: info.componentStack ?? undefined,
});
}
render(): React.ReactNode {
const { error, escalate } = this.state;
if (error && escalate) {
// Rethrowing HERE (render phase) is what React can route upward.
throw error;
}
if (error) return this.props.fallback;
return this.props.children;
}
}
Step-by-step #
- Classify at the throw site.
SessionExpired()carries its own severity, so no boundary has to guess from a message. Untyped errors default tolocal, which is the safe direction: contained. - Report exactly once, at the first catch. The innermost boundary knows the most useful scope. Marking the error means every boundary above stays quiet.
- Decide in
getDerivedStateFromError. This runs in the render phase, so the escalation flag is available before the fallback would render. - Rethrow from
render. This is the crux: React can only route a throw it sees during rendering. A throw insidecomponentDidCatchbecomes an uncaught error. - Bound the escalation count. Two hops is enough for widget → route → session. The counter turns a mis-configured tree (a boundary that always escalates, above another that always escalates) from an infinite loop into a contained failure.
- Keep the original scope. The dashboard should say “comments crashed with a session error”, not “the root boundary crashed”.
| Failure | Severity | Handled by | User sees |
|---|---|---|---|
| Comments API returns 500 | local |
Widget boundary | “Comments are unavailable”, page otherwise fine |
| Malformed comment payload crashes render | local |
Widget boundary | Same, plus a crash report scoped to comments |
| Auth token expired or revoked | session |
Root session boundary | Re-authentication screen, state preserved where possible |
| Runtime config missing a required key | session |
Root session boundary | Full-page error with a support reference |
| Route data loader threw | route |
Route boundary | Route fallback with retry; navigation intact |
| Store hydration produced an invalid shape | session |
Root session boundary | Reset-and-reload prompt |
Avoiding the loops this pattern can create #
Two loops are easy to create and unpleasant to debug.
The escalation loop. Boundary A escalates everything; boundary B above it also escalates everything. React unwinds, re-renders, re-throws — forever. The bumpEscalation counter caps this at two hops regardless of configuration.
The reset loop. The root boundary handles a session error by clearing storage and re-mounting the tree. The re-mounted tree immediately re-reads the same broken config and throws again. The fix is that a session-severity handler must not auto-retry: it presents an action to the user (sign in again, reload) rather than silently retrying. That constraint pairs with the teardown rules in Managing State Reset After Uncaught Promise Rejections.
Verification #
- Throw each severity from a leaf. A
localerror must render the widget fallback; asessionerror must reach the root screen. Use the fault registry from Testing & Fault Injection for Error Boundaries. - Count crash reports. Exactly one per failure, with
scopeequal to the innermost boundary — not the root. - Force a mis-configured tree. Make two nested boundaries both escalate unconditionally; the counter must stop the cycle and the failure must land, not hang the tab.
- Assert the render-phase rethrow. Temporarily move the rethrow into
componentDidCatchand confirm the error escapes towindow.onerrorinstead of the parent — the behaviour that motivates the design. - Check state preservation. After a session escalation, confirm any draft written by Form State Recovery & Multi-Step Wizards is still on disk, so re-authenticating does not cost the user their work.
Frequently asked questions #
Why does throwing inside componentDidCatch not reach the parent boundary?
componentDidCatch runs in the commit phase, after the error was handled, so a throw there is treated as uncaught and escapes to the global handler. Store the error in state and rethrow during render.
Which errors deserve escalation?
Anything the local UI cannot recover from without misleading the user: an expired session, missing runtime configuration, a corrupted store, a failed feature-flag bootstrap. A widget’s own fetch failing is local.
How do I stop the same error being reported at every level?
Set a non-enumerable flag on the error the first time it is reported and check it before reporting again, keeping the original scope in the payload.
Related #
- Error Propagation Strategies — the bubbling model this refines
- How to Prevent Error Boundary Swallowing in Nested Components — the opposite failure: errors that stop too early
- Best Practices for Global vs Local Error Boundaries in SPAs — where each tier of boundary belongs