What you'll learn
Quick Answer
An error boundary is a component that catches JavaScript errors thrown while rendering its children, and shows a fallback UI instead of letting the whole tree unmount. It only catches errors from rendering, lifecycle methods and constructors below it. It does not catch errors in event handlers, in setTimeout or promise callbacks, in the boundary's own render, or during server-side rendering. Those need ordinary try/catch or state.
Why one bad component blanks the whole page
A profile card reads user.address.city. For most users that works. For one user whose address was never filled in, user.address is undefined and the render throws. On a plain React app the result is not a broken card. It is a completely white page, with the navbar, the sidebar and everything else gone.
That behaviour is deliberate. Since React 16, an error thrown during render that nobody catches causes React to unmount the entire component tree. The reasoning is that a half-rendered UI is more dangerous than no UI: a banking screen showing the wrong balance, or a checkout that lost the item count, can cause real damage. React chooses to show nothing rather than something possibly wrong.
In development you do not see the blank page, because the dev overlay shows the stack trace on top. That is why this bug so often reaches production before anyone notices it. On the deployed build, users just see white, and your only clue is a support message saying "the site is not opening".
An error boundary is the mechanism React gives you to opt out of the all-or-nothing behaviour for part of the tree. It catches the error at a boundary you choose, unmounts only the subtree below that point, and renders a fallback in its place. The navbar survives. The rest of the dashboard survives. One card says something went wrong.
The important consequence: without at least one boundary, your app has no recovery path at all. With boundaries placed thoughtfully, a bug in one widget stays a bug in one widget.
Writing a boundary (it has to be a class)
There is no hook version of an error boundary. It is the one thing that still requires a class component, because the mechanism relies on two lifecycle methods with no hook equivalents.
import React from "react";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { error: null };
}
static getDerivedStateFromError(error) {
return { error }; // render phase: decide the next state
}
componentDidCatch(error, info) {
// commit phase: side effects such as logging
console.error(error, info.componentStack);
}
render() {
if (this.state.error) {
return this.props.fallback || <p>Something went wrong.</p>;
}
return this.props.children;
}
}
export default ErrorBoundary;The two methods have different jobs. getDerivedStateFromError runs during rendering and must be pure, so it only returns the new state. componentDidCatch runs afterwards and is where side effects belong: sending the error to a logging service, incrementing a counter, writing to localStorage. The info.componentStack string tells you which component threw, which is often more useful than the JavaScript stack after minification.
Using it is ordinary JSX:
<ErrorBoundary fallback={<p>Could not load your results.</p>}>
<ResultsPanel />
</ErrorBoundary>If writing a class annoys you, the react-error-boundary package wraps this in a component with a hook-friendly API and a reset callback. It is a thin wrapper over the same two lifecycle methods; there is no hidden extra capability, because React does not expose one.
One rule that trips people: a boundary cannot catch an error thrown by its own render. It only catches errors from the tree below it. Keep the fallback simple enough that it cannot throw.
What boundaries do not catch
This is the part that surprises everyone, and it is the most likely thing to be asked about in an interview. Error boundaries catch errors from rendering, from lifecycle methods, and from constructors of components below them. They do not catch:
- Event handlers. An error inside
onClickhappens outside the render cycle, so React does not intercept it. - Asynchronous code.
setTimeout,requestAnimationFrame, promise callbacks andawaitcontinuations all run on a later tick with no React frame around them. - Server-side rendering. Boundaries are a client mechanism; an SSR render error is handled by the framework.
- Errors thrown by the boundary itself. Only children are protected.
So this logs an uncaught error to the console but never shows your fallback:
<button onClick={() => { throw new Error("boom"); }}>Pay</button>For handlers and async work you use ordinary JavaScript error handling, and then put the failure into state so the UI can react to it:
const [payError, setPayError] = useState(null);
async function pay() {
try {
await createOrder(amount);
} catch (err) {
setPayError(err.message); // render a message, do not throw
}
}That is usually the better design anyway: a failed payment should show "Payment could not be completed, no money was deducted" next to the button, not replace the page with a generic fallback.
If you genuinely want an async failure to reach a boundary, you can rethrow it during the next render by throwing inside a state updater:
const [, setError] = useState(null);
// in the catch block:
setError(() => { throw err; });The updater function runs while React is rendering, so the throw happens in the render phase and the boundary catches it. It works, but use it rarely, and never for errors the user could recover from with a retry.
Designing a fallback people can act on
A fallback that says "Something went wrong" and nothing else is only marginally better than a white page. The user still has no move. A useful fallback answers three questions: what failed, what still works, and what can I do now.
function Fallback({ onRetry }) {
return (
<div className="card">
<p>We could not load your test results just now.</p>
<p>Everything else on this page still works.</p>
<button type="button" onClick={onRetry}>Try again</button>
</div>
);
}Retry needs care, because a boundary that has caught an error keeps rendering the fallback forever. Clicking retry must reset the boundary's state and, usually, change something about the children so the same crash does not repeat immediately. The simplest reliable trick is a changing key, which forces React to discard the old subtree and mount a fresh one:
const [attempt, setAttempt] = useState(0);
<ErrorBoundary key={attempt} fallback={<Fallback onRetry={() => setAttempt(a => a + 1)} />}>
<ResultsPanel />
</ErrorBoundary>Three more rules. Keep the fallback dependency-free: no context it might not have, no data fetching, no code that can itself throw. Do not print the raw error message to users, because stack traces and internal endpoint names leak information and mean nothing to them; log the detail, show a sentence. And do log it, with the component stack, otherwise boundaries quietly hide bugs and your error rate looks great while users see broken cards.
Where to place boundaries
One boundary around the whole app is the minimum and it is better than nothing, but it converts every render error into a full-page fallback, which is barely different from the white screen you were trying to avoid. Placement is what makes boundaries worth having.
A practical arrangement for a typical dashboard has three levels:
- Root. One boundary at the top as the last line of defence, with a page that has a reload link and a support email. This one should never normally be seen.
- Route. One per page or route. A crash in the Reports page keeps the navigation and lets the user move to another section instead of reloading.
- Widget. Around independent, risky pieces: anything rendering third-party data, charts, embeds, comment lists, an ad slot. These are the boundaries that actually earn their keep.
The useful question for each candidate spot is: if this subtree disappears, is the rest of the page still worth showing? If yes, it deserves a boundary. If the page is meaningless without it, a boundary there just moves the blank screen inwards.
Two practical notes. Boundaries pair naturally with Suspense: Suspense handles "not ready yet", the boundary handles "failed", and a lazily loaded chunk that fails to download is a render error a boundary can catch, so wrap lazy routes in both. And boundaries are not a substitute for defensive code. Optional chaining on user?.address?.city, a default of [] for a list, and a real check on the API response shape prevent the crash instead of decorating it.
