A boundary is a blast-radius promise.
A dashboard has navigation, an editor, a sales chart, and a save button. If the chart throws while rendering, the user should still be able to navigate and save a draft. If the save request returns an error, the UI should show that request’s state—not wait for a rendering boundary to notice.
The phrase “error boundary” hides two different jobs. A framework boundary catches some failures during rendering and replaces a subtree. A state model represents a failed operation as data that a component can render, retry, or dismiss. The source of the failure determines which job can see it.
The design question is not “where can I catch errors?” It is “what part of the product is allowed to disappear, and what does the user do next?”
Read the boundary-free startTypeScript · framework default decides
export function noBoundary(source: FailureSource): BoundaryOutcome {
return escaped(
'none',
source,
'whole-tree',
['framework-dependent stale UI'],
'reload or let the framework default decide'
);
} With no explicit boundary, each framework supplies a different default. A blank tree is obvious; a stale region is quieter but still a correctness problem. Neither default describes the recovery product wants.
Each position changes what remains usable.
Start with no boundary only when the framework default is an acceptable product decision. A root boundary gives the whole app one fallback. A per-region boundary narrows the promise to the widget that failed. Both catch a throw during render or inside an effect. Failure-as-state covers the paths no boundary sees: click handlers and rejected fetches.
Let the default decide
No designed fallback, no defined surviving state, and framework-specific behavior.
Catch everything at once
A rendering bug gets a fallback, but a sidebar failure can remove the entire app shell.
Replace one failing widget
The chart can reset while navigation, editor, and healthy widgets stay alive.
Render failure as data
Fetches and click handlers can show retry or error state without throwing.
| Position | Render or effect throw | Click handler / fetch | Blast radius | Recovery |
|---|---|---|---|---|
| B1 · None | Framework default | No | Unknown or stale | Reload |
| B2 · Root | Yes | No | Whole app | Reset everything |
| B3 · Region | Yes | No | One widget | Reset one region |
| B4 · State | No | Yes | One component | Refetch or retry |
export function rootBoundary(source: FailureSource): BoundaryOutcome {
if (!thrownInPhase(source))
return escaped(
'root',
source,
'whole-app',
['healthy render, if any'],
'handle in the data or event path'
);
return {
strategy: 'root',
source,
caught: true,
blastRadius: 'whole-app',
survives: ['root fallback'],
action: 'show one global error screen'
};
} export function regionBoundary(source: FailureSource): BoundaryOutcome {
if (!thrownInPhase(source))
return escaped(
'per-region',
source,
'region',
['navigation and sibling regions'],
'handle in the data or event path'
);
return {
strategy: 'per-region',
source,
caught: true,
blastRadius: 'region',
survives: ['navigation', 'editor', 'healthy widgets'],
action: 'show a local fallback and reset the region'
};
} Read failure-as-stateTypeScript · the failures a boundary never sees
export function failureAsState(source: FailureSource): BoundaryOutcome {
if (thrownInPhase(source))
return escaped(
'as-state',
source,
'component',
['intentional state'],
'contain the render or effect bug with a real boundary'
);
return {
strategy: 'as-state',
source,
caught: true,
blastRadius: 'component',
survives: ['shell', 'component state not owned by the request'],
action: 'render loading, retry, empty, or error state'
};
} An explicit state union is designable and testable: the component can show a spinner, empty state, error copy, or retry control. A genuine render defect still needs a boundary so the state model itself does not become the hiding place for bugs.
See the complete programCopyable source plus invocation
// 'render' and 'effect' are synchronous throws during a framework phase, which React error
// boundaries and <svelte:boundary> both catch. 'event' (a click handler) and 'async' (a rejected
// fetch or a timer) run outside those phases, so no rendering boundary ever sees them.
export type FailureSource = 'render' | 'event' | 'async' | 'effect';
function thrownInPhase(source: FailureSource): boolean {
return source === 'render' || source === 'effect';
}
export type BoundaryOutcome = Readonly<{
strategy: 'none' | 'root' | 'per-region' | 'as-state';
source: FailureSource;
caught: boolean;
blastRadius: 'whole-tree' | 'whole-app' | 'region' | 'component';
survives: string[];
action: string;
}>;
function escaped(
strategy: BoundaryOutcome['strategy'],
source: FailureSource,
blastRadius: BoundaryOutcome['blastRadius'],
survives: string[],
action: string
): BoundaryOutcome {
return { strategy, source, caught: false, blastRadius, survives, action };
}
export function noBoundary(source: FailureSource): BoundaryOutcome {
return escaped(
'none',
source,
'whole-tree',
['framework-dependent stale UI'],
'reload or let the framework default decide'
);
}
export function rootBoundary(source: FailureSource): BoundaryOutcome {
if (!thrownInPhase(source))
return escaped(
'root',
source,
'whole-app',
['healthy render, if any'],
'handle in the data or event path'
);
return {
strategy: 'root',
source,
caught: true,
blastRadius: 'whole-app',
survives: ['root fallback'],
action: 'show one global error screen'
};
}
export function regionBoundary(source: FailureSource): BoundaryOutcome {
if (!thrownInPhase(source))
return escaped(
'per-region',
source,
'region',
['navigation and sibling regions'],
'handle in the data or event path'
);
return {
strategy: 'per-region',
source,
caught: true,
blastRadius: 'region',
survives: ['navigation', 'editor', 'healthy widgets'],
action: 'show a local fallback and reset the region'
};
}
export function failureAsState(source: FailureSource): BoundaryOutcome {
if (thrownInPhase(source))
return escaped(
'as-state',
source,
'component',
['intentional state'],
'contain the render or effect bug with a real boundary'
);
return {
strategy: 'as-state',
source,
caught: true,
blastRadius: 'component',
survives: ['shell', 'component state not owned by the request'],
action: 'render loading, retry, empty, or error state'
};
}
export function observe(source: FailureSource) {
return {
none: noBoundary(source),
root: rootBoundary(source),
region: regionBoundary(source),
state: failureAsState(source)
};
}
export function runExample() {
return observe('async');
}
console.log(JSON.stringify(runExample(), null, 2));
Save it as boundaries.ts and run node boundaries.ts (Node 22.18 or
later runs TypeScript directly). It follows one async failure through each boundary position
and prints whether it was caught, what survives, and the next action.
Same UI, different owner.
Choose a boundary position and move the failure from a render expression to a click handler, a fetch, or a throw inside an effect. Predict whether the boundary sees it, what survives, and which recovery action belongs in the component.
Keep the failure fixed. Move the boundary.
UI actionShow a local fallback and retry
Granularity is the design: isolate the region whose failure the user can recover from.
The controls change a local model; nothing is saved.Read the call siteTypeScript · compare every position on one failure
export function observe(source: FailureSource) {
return {
none: noBoundary(source),
root: rootBoundary(source),
region: regionBoundary(source),
state: failureAsState(source)
};
}
export function runExample() {
return observe('async');
} The comparison is deliberately not “catch versus don’t catch.” The per-region boundary catches a render error and protects siblings; the state position owns a failed request and gives the user a retry path. Their responsibilities are different.
Choose the owner of the recovery.
A boundary decision is also a product decision: which regions remain useful, which action is available, and whether the failure is a bug or an expected outcome.
Contain bugs. Model operations. Preserve intent.
Place boundaries around independent regions whose fallback still lets the user do something valuable. Log the original render error with enough context to diagnose it. Keep the fallback recovery small: remount the region, retry a safe read, or offer navigation away.
At the same time, keep network and event failures in explicit state. The request layer can map a public error code to “retry,” “sign in,” or “edit conflict.” The boundary should not need to know whether a server returned a 409 or a 503, because the operation state already owns that contract.
Contain the bug
Use the smallest region whose fallback is meaningful.
Model the outcome
Keep loading, success, and error state beside the operation.
Preserve intent
Keep navigation, drafts, and safe actions available after a local failure.
A React class boundary and Svelte svelte:boundary contain a chart render bug and provide a local reset.
import { Component, type ErrorInfo, type ReactNode } from 'react';
type Props = { children: ReactNode };
type State = { failed: boolean };
export class WidgetBoundary extends Component<Props, State> {
state: State = { failed: false };
static getDerivedStateFromError(): State {
return { failed: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
logRenderFailure(error, info.componentStack);
}
render() {
if (this.state.failed) {
return <button onClick={() => this.setState({ failed: false })}>Try chart again</button>;
}
return this.props.children;
}
}
function logRenderFailure(error: Error, componentStack: string) {
console.error('render failure', { error, componentStack });
}
export function Dashboard() {
return (
<main>
<aside>Navigation</aside>
<WidgetBoundary>
<SalesChart />
</WidgetBoundary>
</main>
);
}
function SalesChart() {
return <section>Chart</section>;
}
Build UIs?You already have several recovery boundaries.
Where it already is in your components
A route-level error page, a list’s empty state, and a button’s pending or disabled state are all boundary decisions. The useful question is whether the state came from rendering, an operation, or navigation, because each has a different owner.
When you have to own it
When your app has several independent widgets, choose their boundaries deliberately and test that a failed one leaves the shell usable. When an error can be expected, represent it as a value before the view so it is not dependent on a framework-specific catch phase.
Frameworks expose the same tradeoff with different syntax.
React error boundary
A class boundary catches render, lifecycle, and effect throws below it; event handlers and async work need their own path.
<svelte:boundary>
Svelte’s boundary pairs a failed snippet with a reset function, keeping the fallback local to the block.
Solid ErrorBoundary
Solid also separates a rendering fallback from resource or event state; the recovery boundary remains a region decision.
A fallback can hide the wrong problem.
A boundary does not catch every failure
React error boundaries and <svelte:boundary> catch a throw during render
and a synchronous throw inside an effect. Event handlers, timers, async callbacks, and failed
network requests happen outside those phases, including a promise that rejects after an effect
has returned. Handle them where they occur or turn them into state. If a framework offers an
async boundary, verify precisely which lifecycle it covers rather than assuming it catches promises.
Do not wrap the whole app by reflex
A root fallback prevents a blank screen but can remove navigation, unsaved work, and the user’s route away from the broken widget. Keep a root safety net if you need one, then add smaller boundaries where the product has independent regions.
Reset can destroy useful state
A remount may clear form input, selection, or an in-progress draft. Make the reset scope clear and keep durable intent outside the subtree when losing it would be surprising.
Do not turn expected failure into an exception
An unavailable service, empty search, or validation problem is often a normal state to render. A boundary is useful for a bug; throwing expected outcomes makes recovery harder to design and test.
Give each failure the smallest honest owner.
Use boundaries to contain defects during rendering. Use local or request state for recoverable operations. Keep a root fallback as a last line of defense, not as the design for every widget.
Boundary the region.
Replace the failing subtree and preserve independent UI.
Render it as state.
Make loading, empty, error, and retry paths explicit and testable.
Keep a root net.
Show a safe escape hatch while preserving logs and route context where possible.
Contain the bug; preserve the user’s intent.
A UI boundary defines what may disappear when rendering fails. It is a product promise about blast radius, not a replacement for operation state. Keep those two axes separate and your fallback can stay local while retries, validation, and server errors remain ordinary values.
This lesson chooses what the interface can keep doing.
- Why
- Keep a local failure from taking useful UI with it.
- What
- Contain render bugs; model event and async failures as state.
- Constraint
- Each boundary needs a reset and a rule for what state survives it.
- Fallback
- A root boundary catches what no region owns.
- Reconsider when
- The region, reset action, or failure owner changes.
Connections to follow nextRelated lessons
- Expected vs. unrecoverable decides which failures belong in state and which are defects.
- Errors across a boundary sets the contract before a response reaches the UI.
- Discriminated unions give the failure-as-state position its shape: loading, loaded, failed.
- Retry, backoff, and idempotency is what the retry button should do once it exists.