← Concepts & practices
Choice Errors, results, and recovery

Error boundaries in UI

Choose the blast radius—and the state that survives it.

A chart can fail while navigation, a draft, and the rest of the page remain useful. A rendering boundary can replace that chart when it throws during render or inside an effect. It cannot catch the failed fetch that loaded it or the click handler that saves the draft. Compare the four positions, then separate blast-radius control from ordinary failure state.

The judgment to keep

Use a rendering boundary to contain a render or effect bug at the smallest useful region. Use explicit loading, success, and error state for click handlers and fetches. The two mechanisms cooperate; neither replaces the other.

TypeScript React · Svelte · Solid One dashboard · four boundary positions
Start with the failure

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
boundaries.ts · no boundary
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.

Four positions

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.

B1 · No boundary

Let the default decide

No designed fallback, no defined surviving state, and framework-specific behavior.

B2 · Root boundary

Catch everything at once

A rendering bug gets a fallback, but a sidebar failure can remove the entire app shell.

B3 · Per region

Replace one failing widget

The chart can reset while navigation, editor, and healthy widgets stay alive.

B4 · As state

Render failure as data

Fetches and click handlers can show retry or error state without throwing.

What each position catches
PositionRender or effect throwClick handler / fetchBlast radiusRecovery
B1 · NoneFramework defaultNoUnknown or staleReload
B2 · RootYesNoWhole appReset everything
B3 · RegionYesNoOne widgetReset one region
B4 · StateNoYesOne componentRefetch or retry
B2 · Root
boundaries.ts · root boundary
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'
	};
}
B3 · Region
boundaries.ts · per-region boundary
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
boundaries.ts · failure as state
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
boundaries.ts
// '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.

Move the failure

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.

A controlled comparison

Keep the failure fixed. Move the boundary.

Runs a local UI failure model
FailureRender expression
BoundaryA boundary per region
Resultcontained
Catches it?Yes
Blast radiusOne widget or region
What survivesNavigation and sibling regions
RecoveryReset only the failed region

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
boundaries.ts
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.

Make the boundary explicit

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.

A chart renderer can fail while navigation and the editor remain useful.
A save request returns a 503 from a click handler.
A render bug should not be disguised as an empty result.
Feedback stays on this page; it is not saved.
A production UI

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.

Render

Contain the bug

Use the smallest region whose fallback is meaningful.

Request / event

Model the outcome

Keep loading, success, and error state beside the operation.

Product

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.

ReactAlready in your code
textbook.tsx · React boundary
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.

Recognize it elsewhere

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.

See async UI ownership in race conditions ↗
The parts to watch

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.

Make the call

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.

Render bug

Boundary the region.

Replace the failing subtree and preserve independent UI.

Expected outcome

Render it as state.

Make loading, empty, error, and retry paths explicit and testable.

Whole-app defect

Keep a root net.

Show a safe escape hatch while preserving logs and route context where possible.

Take the idea with you

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
Explore more concepts & practices →