“One widget threw an error and the whole page went white. How do you prevent that?” Since React 16, an uncaught render error unmounts the entire root, so a single bad API response in a sidebar can blank a checkout page. Interviewers ask about error boundaries to see whether you know what they catch, where to put them, and how users recover.
Strong answers go beyond “wrap the app in an error boundary”. They describe scope (what should fail together), the errors boundaries cannot see, and a reset strategy that does not require reloading the page.
Before you start
You should know class components at a basic level, because error boundaries are still written as classes (or via a library such as react-error-boundary). You should understand the render and commit phases and know what Suspense boundaries do. React 19 added root-level options onCaughtError and onUncaughtError to createRoot and hydrateRoot, and reports each error once instead of logging duplicates.
The short answer
An error boundary is a class component that implements static getDerivedStateFromError (to render a fallback) and usually componentDidCatch (to log). It catches errors thrown while rendering its children, in their lifecycle methods and in their effects, and replaces that subtree with a fallback while the rest of the page keeps working. It does not catch errors in event handlers, in asynchronous code like setTimeout or promise callbacks, during server rendering, or in itself. Place boundaries around regions that are useful on their own, and give each fallback a way to retry, typically by resetting the boundary when the relevant data changes.
How it works
import { Component } from 'react';
class ErrorBoundary extends Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error };
}
componentDidCatch(error, info) {
reportError(error, info.componentStack);
}
render() {
if (this.state.error) {
return this.props.fallback({
error: this.state.error,
reset: () => this.setState({ error: null }),
});
}
return this.props.children;
}
}When a descendant throws during render, React walks up the tree to the nearest boundary, calls getDerivedStateFromError during rendering to compute the fallback state, renders the fallback, then calls componentDidCatch after commit with a component stack. Everything outside the boundary stays mounted and interactive.
Errors in event handlers do not happen during rendering, so React does not route them to boundaries. Handle them with try/catch and state, or rethrow them into render deliberately:
const [error, setError] = useState(null);
if (error) throw error; // now a boundary can catch it
async function onSave() {
try { await save(); } catch (e) { setError(e); }
}Rejected promises read with use() inside Suspense are thrown during render, so boundaries do catch those.
Step-by-step walkthrough
Step 1: Decide what should fail together
Map the page into regions that are useful on their own. On a dashboard, each widget is independent; in a checkout, the order summary and payment form depend on each other and should fail together. A boundary’s scope equals the blast radius of a failure.
Step 2: Add a root boundary as a last resort
A top-level boundary prevents a blank screen and offers a reload or a link home. It is necessary but not sufficient, because everything below it is lost when it triggers.
Step 3: Add region boundaries with useful fallbacks
<ErrorBoundary fallback={({ reset }) => (
<WidgetError message="Revenue chart could not load" onRetry={reset} />
)}>
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart range={range} />
</Suspense>
</ErrorBoundary>Pair each data-loading Suspense boundary with an error boundary at the same level, so every region has a loading state and a failure state with the same footprint.
Step 4: Reset when the cause changes
A retry button that resets the boundary will re-render the same children with the same props; if the data is still broken, it fails again. Reset also when the inputs change. The simplest way is a key:
<ErrorBoundary key={range} fallback={...}>
<RevenueChart range={range} />
</ErrorBoundary>Changing the range remounts the boundary, clearing the error. The reset with key note explains the mechanism. Libraries like react-error-boundary offer resetKeys for the same purpose.
Worked scenario
A banking dashboard had one boundary at the root. A malformed transaction from a partner API (a missing amount field) made amount.toFixed(2) throw inside a “Recent activity” widget. Customers saw a full-page “Something went wrong” screen and could not reach their balance or transfers, which caused a spike in support calls.
The team made three changes. First, each dashboard widget got its own boundary with a compact fallback and a retry button. Second, the boundary around “Recent activity” was keyed on the selected account, so switching accounts cleared the error. Third, the logging in componentDidCatch included the component stack and the account type, which led to the partner bug within an hour.
<ErrorBoundary key={accountId} fallback={({ reset }) => (
<Card>
<p>Recent activity is unavailable right now.</p>
<button onClick={reset}>Try again</button>
</Card>
)}>
<RecentActivity accountId={accountId} />
</ErrorBoundary>The next time a bad record arrived, only that card showed an error; balances and transfers kept working. The root boundary remained for truly unexpected failures.
They also fixed the root cause by validating API responses at the data layer, because boundaries contain failures but do not prevent them.
Common mistake
- “Error boundaries catch all errors.” Not event handlers, not async callbacks, not server rendering, not errors in the boundary itself.
- Only a root boundary. One bad widget takes down the whole page.
- A boundary around every component. Fallbacks everywhere make a page look broken in a dozen places and are hard to design.
- A retry that changes nothing. If props and data are the same, the child throws again. Refetch or reset on input changes.
- Swallowing errors without logging. Always report from
componentDidCatchor the rootonCaughtErrorhandler.
Verify the behavior
Use a component that throws on demand:
function Bomb({ explode }) {
if (explode) throw new Error('boom');
return <p>widget ok</p>;
}
export default function App() {
const [explode, setExplode] = useState(false);
return (
<>
<button onClick={() => setExplode(true)}>break widget</button>
<p>header still works</p>
<ErrorBoundary key={String(explode)} fallback={({ reset }) => (
<button onClick={() => { setExplode(false); reset(); }}>widget failed, retry</button>
)}>
<Bomb explode={explode} />
</ErrorBoundary>
</>
);
}Clicking “break widget” replaces only the widget with its fallback; the header text remains. Clicking retry restores the widget. In development, React shows the error overlay in some setups even when a boundary catches it; dismiss it and check that the page underneath behaves as expected. In a test, use React Testing Library and assert that the fallback appears and the sibling text is still present.
Follow-up questions
Why are error boundaries still classes? There is no hook equivalent of getDerivedStateFromError yet. Most teams use react-error-boundary, which wraps a class and exposes a function-component API.
How do error boundaries relate to Suspense? Suspense handles “not ready”, error boundaries handle “failed”. A rejected promise in a Suspense-enabled read is thrown and caught by the nearest error boundary.
What do onCaughtError and onUncaughtError do in React 19? They are root options for centralised reporting: one fires for errors caught by a boundary, the other for errors that reached the root. onRecoverableError covers errors React recovered from automatically, such as hydration mismatches.
Do frameworks provide boundaries? Yes. In the Next.js App Router, an error.js file creates an error boundary around a route segment, which is the same idea with routing built in.
Interview exercise
A form’s submit handler calls await api.save() and the API sometimes rejects. The team wrapped the form in an error boundary, but failures still produce an unhandled promise rejection and no fallback. Explain why, and give two ways to show an error state.
Answer and reasoning
The rejection happens in an async event handler, outside rendering, so React never routes it to the boundary; it surfaces as an unhandled promise rejection. Option one, usually best for forms: catch it locally with try/catch, store the message in state, and render an inline error near the submit button while keeping the user’s input. Option two, when the failure should replace the region: store the error in state and throw it during the next render (if (error) throw error), which the boundary then catches. In React 19, using a form action with useActionState is another option: return an error result from the action and render it. See useActionState forms.
Continue learning
Practise with the React interview questions and the React MCQs. Related notes: Suspense boundaries, resetting with a key and useActionState forms. Official reference: Catching rendering errors with an error boundary in the Component reference on react.dev.