#Error boundaries
When a component throws while rendering, React can't produce a consistent UI for that part of the tree, so it removes it. Without anything to stop it, the error unwinds all the way up and React unmounts the entire app — the user gets a blank page. An error boundary is a component that stops that unwinding: it catches errors thrown while rendering anything below it, and renders a fallback UI instead.
From my notes: error boundaries "catch errors that occur during rendering, in lifecycle methods, and in the constructors of child components", and they "help prevent the entire application from crashing by isolating where the error occurred and rendering a fallback UI, such as an error message."
The mental model is a circuit breaker in a fuse box. A fault in one room trips that room's breaker; the lights stay on in the rest of the house. Where you put breakers decides how much goes dark: one at the root (the whole app shows "Something went wrong"), one per route, or one per widget (only the broken chart disappears).
There is still no hook for this in React 19.3: an error boundary must be a class component that defines static getDerivedStateFromError (and usually componentDidCatch). In practice most apps use react-error-boundary, which is that class, written once.
import { Component, useState } from "react";
export class ErrorBoundary extends Component {
state = { error: null };
// Render phase: turn the error into state. Must be pure.
static getDerivedStateFromError(error) {
return { error };
}
// Commit phase: side effects such as logging.
componentDidCatch(error, info) {
this.props.onError?.(error, info.componentStack);
}
render() {
if (this.state.error) {
return (
<div role="alert">
<p>Something went wrong: {this.state.error.message}</p>
<button onClick={() => this.setState({ error: null })}>Try again</button>
</div>
);
}
return this.props.children;
}
}
export function BuggyCounter() {
const [count, setCount] = useState(0);
if (count === 3) throw new Error("I crashed at 3!");
return <button onClick={() => setCount((c) => c + 1)}>Count: {count}</button>;
}
export function App({ onError }) {
return (
<main>
<ErrorBoundary onError={onError}>
<BuggyCounter />
</ErrorBoundary>
<p>The rest of the page keeps working.</p>
</main>
);
}- On first render,
ErrorBoundary's state is{ error: null }, so it renders its children: the counter shows "Count: 0" and the paragraph below it renders normally. - Three clicks take
countfrom0 → 1 → 2 → 3. On the render wherecount === 3,BuggyCounterthrows instead of returning JSX. - React stops rendering that subtree and walks up the tree to the nearest class component with
getDerivedStateFromError. It calls it with the error; the returned{ error }becomes the boundary's new state, and React re-renders the boundary — now showing the fallback "Something went wrong: I crashed at 3!". - After the fallback is committed to the DOM, React calls
componentDidCatch(error, info). That's the place for side effects: here it passes the error andinfo.componentStack(a string starting with theBuggyCounterframe) toonError, which in a real app would go to your error-reporting service. - The paragraph outside the boundary is untouched — the breaker tripped for one room only. The test checks the alert, the paragraph, and that
onErrorreceived the error. - "Try again" sets
errorback tonull, so the boundary renders its children again.BuggyCounterwas unmounted when it crashed, so it remounts with fresh state (count0) and works again.
React 19.3 also logs the caught error once with console.error. In the test, the logged arguments are the error object followed by "The above error occurred in the <BuggyCounter> component." and "React will try to recreate this component tree from scratch using the error boundary you provided, ErrorBoundary." Before React 19, a caught error was logged more than once in development (the error itself was re-thrown and reported as uncaught, plus the "above error" message); React 19 reports each error once. What gets logged, and where, can be changed with the root error callbacks.
getDerivedStateFromError vs componentDidCatch
static getDerivedStateFromError(error) | componentDidCatch(error, info) | |
|---|---|---|
| When it runs | render phase, before the fallback is shown | commit phase, after the fallback is in the DOM |
| Job | return state that switches to the fallback | side effects: logging, analytics |
Has this / side effects | no — static and must be pure | yes |
| Gets the component stack | no | yes, info.componentStack |
You need getDerivedStateFromError to show a fallback. A class with only componentDidCatch is still treated as a boundary, but React 19.3 warns in development: "OnlyDidCatch: Error boundaries should implement getDerivedStateFromError(). In that method, return a state update to display an error message or fallback UI." The React 16.0 pattern of calling setState in componentDidCatch to switch the UI is legacy.
Error boundaries vs try/catch
From my notes: "Error boundaries are React's way of handling component rendering failures. While try/catch works for imperative code, error boundaries handle failures that occur during the component lifecycle, which is declarative in React." The clearest way to see why you can't just use try/catch:
function Bomb() {
throw new Error("render boom");
}
export function Wrapper() {
try {
return <Bomb />;
} catch {
return <p>caught by try/catch</p>; // never runs
}
}- React calls
Wrapper(). Inside thetry,<Bomb />is evaluated — but JSX doesn't callBomb. It only creates an element object,{ type: Bomb, props: {} }, and nothing throws. Wrapperreturns that object and thetryblock exits normally. Thecatchcan never run.- Later, React renders the element: it calls
Bomb(), which throws "render boom". By then,Wrapper'stryis long gone from the call stack. - With no boundary above, the error is uncaught and the root unmounts. The test shows
render(<Wrapper />)throwing "render boom". - That's the declarative/imperative difference from my notes: you describe the tree, React renders it later, so error handling for rendering has to be part of the tree too — a component above the failing one.
try/catch is still the right tool for imperative code you call yourself: inside event handlers, inside effects, around await in async functions (see Handling errors in events and async code).
"Try again" above works by clearing the boundary's state. Another common technique is giving the boundary a key tied to whatever might fix the error — the current route or the selected item: <ErrorBoundary key={userId}>. When userId changes, React treats it as a different boundary, unmounts the old one (and its error state) and mounts a fresh one. react-error-boundary's resetKeys prop is the same idea without remounting the boundary itself.