Error Boundaries
An error boundary is a component that catches JavaScript errors thrown anywhere in its child component tree during rendering, stops the crash from propagating to the whole app, logs the error, and displays a fallback UI instead of a blank white screen. Without error boundaries, a single thrown error inside any component unmounts the entire React tree, leaving the user with nothing on screen. Error boundaries let you isolate failures to a small part of the UI and keep the rest of the app usable.
Overview / How it works
By default, if a component throws an error while rendering, in a lifecycle method, or in a constructor, React unmounts the whole component tree it was rendering in order to avoid leaving the UI in a corrupted, half-rendered state. This is a deliberate design decision: React would rather show nothing than show something wrong. Error boundaries give you a way to opt out of that all-or-nothing behavior for a specific subtree.
An error boundary is a component that implements one or both of two special lifecycle APIs: static getDerivedStateFromError(error) and componentDidCatch(error, errorInfo). When a descendant throws during rendering, React walks up the tree looking for the nearest ancestor that defines these methods. If it finds one, React calls getDerivedStateFromError to compute new state (used to render a fallback UI on the next render) and calls componentDidCatch as a side effect for logging. If no ancestor is an error boundary, the error keeps propagating and the whole tree unmounts.
Here is the important part every React developer eventually has to learn: as of React 18/19, error boundaries can only be written as class components. There is no hook equivalent of getDerivedStateFromError or componentDidCatch — React has not shipped a hooks-based way to catch render errors from children. This is the one legitimate exception to “function components and hooks only”: you either write a small class component yourself (it can live in an otherwise all-function-component codebase, used only for this one job), or you use a well-maintained library such as react-error-boundary, which wraps that class internally and exposes a friendly function-component-style API to you.
What error boundaries catch
- Errors thrown during rendering of child components
- Errors thrown in lifecycle methods of child class components
- Errors thrown in constructors of the tree below the boundary
What error boundaries do NOT catch
- Errors inside event handlers (e.g. a
throwinside anonClick) — these don’t happen during rendering, so use a normaltry/catchinstead - Errors in asynchronous code, such as inside
setTimeoutcallbacks or unresolved promises - Errors thrown during server-side rendering
- Errors thrown in the error boundary’s own code (a boundary cannot catch its own errors; a parent boundary further up the tree is needed for that)
This is why error boundaries are best thought of as a safety net for rendering bugs and unexpected data shapes, not a general-purpose replacement for try/catch throughout your app.
Syntax
class ErrorBoundary extends Component {
static getDerivedStateFromError(error) {
// return new state so the next render shows a fallback UI
}
componentDidCatch(error, errorInfo) {
// log the error to a service, inspect errorInfo.componentStack
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
| Part | Purpose |
|---|---|
getDerivedStateFromError(error) |
Static method called during the “render” phase, right after a descendant throws. Must return a plain object to update state with (used to switch to fallback UI). Cannot cause side effects like logging. |
componentDidCatch(error, errorInfo) |
Called during the “commit” phase, after the fallback has been committed. Safe place for side effects like sending the error to a logging service. errorInfo.componentStack gives you the component tree where the error occurred. |
this.props.children |
The subtree the boundary is protecting; rendered normally when there is no error. |
Examples
Example 1: A reusable ErrorBoundary component
import { Component } from "react";
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo.componentStack);
}
render() {
if (this.state.hasError) {
return <h2>Something went wrong.</h2>;
}
return this.props.children;
}
}
export default ErrorBoundary;
This renders its children normally. If any child throws while rendering, React calls getDerivedStateFromError, which flips hasError to true, so on the next render the component shows <h2>Something went wrong.</h2> instead of crashing the whole page. componentDidCatch logs the error and the component stack for debugging.
Example 2: Wrapping a component that can throw, with a reset button
import { Component, useState } from "react";
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error("Logged to monitoring service:", error, errorInfo.componentStack);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return (
<div>
<p>Something went wrong: {this.state.error.message}</p>
<button onClick={this.handleReset}>Try again</button>
</div>
);
}
return this.props.children;
}
}
function BuggyCounter({ count }) {
if (count === 3) {
throw new Error("Counter hit an unlucky number: 3");
}
return <p>Count: {count}</p>;
}
function App() {
const [count, setCount] = useState(0);
return (
<ErrorBoundary>
<BuggyCounter count={count} />
<button onClick={() => setCount(count + 1)}>Increment</button>
</ErrorBoundary>
);
}
export default App;
Clicking “Increment” three times sets count to 3. BuggyCounter throws during its render, React unmounts the failing subtree, and ErrorBoundary catches it — instead of the counter UI, the page now shows “Something went wrong: Counter hit an unlucky number: 3” with a “Try again” button. Clicking that button resets hasError to false, remounting BuggyCounter, which will immediately throw again because count is still 3 — in a real app you’d also reset the state that caused the crash, not just the boundary’s own state.
Example 3: Using the react-error-boundary library
import { ErrorBoundary } from "react-error-boundary";
function Fallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<p>Something went wrong:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
function BuggyWidget() {
throw new Error("Widget failed to load");
}
function App() {
return (
<ErrorBoundary
FallbackComponent={Fallback}
onReset={() => window.location.reload()}
onError={(error, info) => console.error(info.componentStack)}
>
<BuggyWidget />
</ErrorBoundary>
);
}
export default App;
This renders the alert box from Fallback because BuggyWidget throws immediately on mount. The react-error-boundary package gives you the class-component machinery under the hood while exposing a declarative, function-component-friendly API (FallbackComponent, onReset, onError, and a resetErrorBoundary function passed to your fallback), which is why most modern React codebases reach for this library instead of hand-writing the class each time.
How it works step by step
- Normal render: The error boundary renders
this.props.childrenlike any wrapper component. No special behavior happens as long as nothing throws. - A child throws during render: React catches the thrown value at the boundary. It first calls
getDerivedStateFromError(error)synchronously (render phase) to compute the state that will produce the fallback UI. - Commit phase: React commits the fallback UI to the DOM, unmounting the failed subtree. After the commit, it calls
componentDidCatch(error, errorInfo)so you can log the error without blocking the render. - Fallback is shown: The user sees your fallback UI (a message, a button, an illustration) instead of a blank screen or a stack trace.
- Optional reset: If you provide a way to clear
hasError(a button, a route change, a prop-drivenkeyremount), React tries to render the children again from scratch.
Common Mistakes
Mistake 1: Expecting an error boundary to catch event handler errors
function App() {
return (
<ErrorBoundary>
<button onClick={() => { throw new Error("Oops"); }}>Click me</button>
</ErrorBoundary>
);
}
This will crash the app anyway, because the error happens inside an event handler, not during rendering — error boundaries never see it. Handle it locally instead:
function App() {
const handleClick = () => {
try {
riskyOperation();
} catch (error) {
console.error("Handled click error:", error);
}
};
return <button onClick={handleClick}>Click me</button>;
}
Mistake 2: Trying to write an error boundary as a function component
function ErrorBoundary({ children }) {
const [hasError, setHasError] = useState(false);
if (hasError) {
return <p>Something went wrong.</p>;
}
return children; // does NOT catch errors thrown by children
}
There is no hook that runs when a descendant throws during rendering, so this component never actually detects the error — hasError never becomes true and the app still crashes. You must use a class component with getDerivedStateFromError/componentDidCatch, or the react-error-boundary library, as shown in Example 3.
Mistake 3: One giant boundary around the whole app
Wrapping only the root <App /> in a single error boundary means any error anywhere replaces the entire UI with a fallback. Place boundaries around independent sections (a sidebar, a chart widget, a comment thread) so one broken widget doesn’t take the rest of the page down with it.
Best Practices
- Place multiple, granular error boundaries around independent sections of the UI rather than one boundary at the very top.
- Always log errors from
componentDidCatchto a monitoring service (Sentry, LogRocket, or your own backend) — don’t just leave a fallback with no visibility into what broke. - Give the fallback UI a way to recover: a “Try again” button, a link back to a safe route, or resetting the state that caused the crash.
- Reach for the
react-error-boundarylibrary in real projects instead of hand-rolling the class every time — it also offers auseErrorBoundaryhook for manually triggering the nearest boundary from event handlers or async code. - Remember error boundaries do not catch event handler or async errors — use
try/catchor.catch()for those, and manually report them if needed. - Use a
keyprop on the wrapped subtree (or on the boundary itself) tied to some identifying value, so that a reset can force a full remount when simply flipping local state isn’t enough.
Practice Exercises
- Write an
ErrorBoundaryclass component with a fallback prop, so callers can pass any JSX (like<ErrorBoundary fallback={<p>Oops</p>}>) instead of a hardcoded message. - Build a
ProfileCardcomponent that throws if auserprop isnull, wrap it in your error boundary, and verify (by reasoning through the render/commit steps) what the user sees whenuseris missing versus present. - Add a “Try again” button to your boundary’s fallback that resets its error state, and explain why the underlying bug could still reappear immediately after clicking it unless the triggering data also changes.
Summary
- Error boundaries catch errors thrown during rendering, in lifecycle methods, and in constructors of their child tree, preventing the whole app from unmounting.
- They are implemented with
static getDerivedStateFromError(error)(render phase, computes fallback state) andcomponentDidCatch(error, errorInfo)(commit phase, for logging). - Error boundaries must currently be class components — there is no hook equivalent, making this the one legitimate exception to the function-component-only rule.
- They do not catch errors in event handlers, async code, or their own rendering code — use
try/catchfor those cases. - The
react-error-boundarylibrary provides a friendly, reusable API built on top of the class-based mechanism, and is the common real-world choice. - Use several small, targeted boundaries rather than one giant boundary, and always give users a way to recover.
