An error boundary is a React class component that catches JavaScript errors anywhere in its child component tree, logs them, and renders a fallback UI instead of crashing the whole application.
Before error boundaries existed, a single uncaught render error would unmount the entire React tree and leave users with a blank screen. Error boundaries provide fault isolation — only the subtree that threw is replaced with a fallback; everything outside the boundary keeps running normally.
// Without an error boundary, one bad render kills the whole app.
// With one, only the ErrorWidget section crashes — the rest survives.
<App>
<Header /> {/* still renders fine */}
<ErrorBoundary fallback={<p>Widget failed.</p>}>
<ErrorWidget /> {/* throws → caught here, fallback shown */}
</ErrorBoundary>
<Footer /> {/* still renders fine */}
</App>
Rule of thumb: An error boundary is a circuit breaker — it stops one bad component from taking down the entire user interface.
getDerivedStateFromError is a static lifecycle method called during the
render phase when a descendant throws. It receives the thrown error and must
return an object that is merged into the component's state — typically a flag
that switches the component from rendering children to rendering the fallback UI.
Because it runs during the render phase it must be pure and side-effect free.
Use it only to update state; do all logging in componentDidCatch instead.
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
// Called during render phase — update state to trigger fallback
static getDerivedStateFromError(error) {
return { hasError: true, error }; // pure — no side effects here
}
render() {
if (this.state.hasError) {
return <p>Something went wrong.</p>; // fallback UI
}
return this.props.children; // normal path
}
}
Rule of thumb: getDerivedStateFromError answers what to show; it must
stay pure — keep it to one return statement that updates state.
componentDidCatch is called during the commit phase, after the fallback UI
has been painted. Unlike getDerivedStateFromError it receives both the error
and an info object ({ componentStack }) that contains the component stack
trace. It is the right place for side effects such as logging to an error
monitoring service.
The two methods are complementary: getDerivedStateFromError flips the state to
show the fallback; componentDidCatch does the logging.
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true }; // render phase — only flip the flag
}
componentDidCatch(error, info) {
// Commit phase — side effects are safe here
// info.componentStack is the React component call stack
logErrorToSentry(error, { extra: { componentStack: info.componentStack } });
}
render() {
return this.state.hasError
? <FallbackUI />
: this.props.children;
}
}
Rule of thumb: getDerivedStateFromError = pure state update (render phase);
componentDidCatch = side effects like logging (commit phase). Use both together.
Error boundaries require getDerivedStateFromError and componentDidCatch, which
are class lifecycle methods with no hook equivalents in React's public API. The
React team has acknowledged this gap but has not shipped a functional replacement —
as of React 18 you still need a class component.
The react-error-boundary library wraps a class boundary behind a friendly
functional API, which is the recommended way to avoid writing class components
yourself in modern codebases.
// The class is unavoidable at the boundary itself…
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
render() {
return this.state.hasError
? this.props.fallback
: this.props.children;
}
}
// …but you can wrap it so callers use a clean functional component:
function SafeWidget({ children }) {
return (
<ErrorBoundary fallback={<p>Widget failed</p>}>
{children}
</ErrorBoundary>
);
}
Rule of thumb: You need a class somewhere in the chain — use
react-error-boundary to hide it so the rest of your codebase stays functional.
Error boundaries catch errors that occur during rendering, lifecycle methods, and constructors of class components in the tree below them. They do NOT catch:
- Event handlers — React does not call event handlers during rendering; wrap
them in a regular
try/catchinstead. - Asynchronous code — errors in
setTimeout, Promises, orasync/awaitare not caught because they happen outside React's call stack. - Server-side rendering — boundaries only work client-side.
- Errors thrown inside the boundary itself — a boundary cannot catch its own render errors.
function BadButton() {
const handleClick = () => {
try {
riskyOperation(); // must use try/catch — boundary won't catch this
} catch (err) {
reportError(err);
}
};
return <button onClick={handleClick}>Go</button>;
}
async function fetchData() {
// Promise rejection — NOT caught by error boundary
// Use .catch() or try/catch inside useEffect instead
const data = await api.get('/things');
}
Rule of thumb: If the error happens outside a React render call — in a callback or a Promise — an error boundary will not catch it.
Coarser boundaries protect large sections but may hide too much on failure. Finer boundaries give surgical fallbacks but add boilerplate. A typical three-level strategy works well:
- App/root level — one top-level boundary as a last resort fallback (blank screen prevention).
- Route/page level — a boundary per route so a broken page doesn't kill navigation.
- Widget/feature level — boundaries around independent sidebar panels, recommendation carousels, or ad slots that can fail without affecting the main content.
<RootErrorBoundary> {/* level 1 — last resort */}
<Router>
<Routes>
<Route /* level 2 — per route */
element={
<RouteErrorBoundary>
<DashboardPage />
</RouteErrorBoundary>
}
/>
</Routes>
<Sidebar>
<WidgetErrorBoundary> {/* level 3 — isolated widget */}
<RecommendationsPanel />
</WidgetErrorBoundary>
</Sidebar>
</Router>
</RootErrorBoundary>
Rule of thumb: Place a boundary wherever you can write a meaningful, scoped fallback message — the finer the boundary, the better the user experience.
A well-designed fallback UI tells users what happened, what they can do next, and ideally provides a recovery action (reload, retry, go home). Avoid exposing raw error messages or stack traces in production.
Good fallback UIs are:
- Scoped — show only in the crashed section, not full-page unless necessary.
- Actionable — include a retry button or a link to the home page.
- Branded — styled consistently, not a plain white box.
- Non-alarming — phrase errors in friendly, non-technical language.
function FallbackUI({ error, resetErrorBoundary }) {
return (
<div role="alert" className="error-panel">
<h2>This section couldn't load.</h2>
{/* Never show error.message to users in production */}
<p>Try refreshing or come back later.</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
);
}
Rule of thumb: A fallback UI should answer "what broke and what can I do?" — never dump a raw error message onto users.
Recovery works by resetting the boundary's error state so it re-renders its
children on the next render. In a hand-rolled boundary, expose a reset method
or pass a onReset callback. In react-error-boundary, use the
resetErrorBoundary prop injected into the fallback component.
The key subtlety: if the component that threw hasn't changed, it will throw again immediately. Pair the reset with a key change or a data refetch so the re-render has a chance to succeed.
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
// Expose reset so parent or fallback can call it
reset = () => this.setState({ hasError: false });
render() {
if (this.state.hasError) {
return (
<FallbackUI onRetry={this.reset} /> // pass reset down
);
}
return this.props.children;
}
}
Rule of thumb: Always give users a retry path — but also make sure the retry actually fixes the underlying condition, not just re-throws the same error.
react-error-boundary is the community-standard wrapper that adds four key
features on top of the raw class API:
<ErrorBoundary>— a ready-made class boundary that acceptsfallback,FallbackComponent,onError,onReset, andresetKeysprops.FallbackComponent— receives{ error, resetErrorBoundary }so the fallback can trigger recovery without prop drilling.resetKeys— an array of values; when any changes the boundary auto-resets, enabling data-driven recovery.useErrorBoundary()— hook that lets any functional component throw errors up to the nearest boundary programmatically (useful for async errors).
import { ErrorBoundary } from 'react-error-boundary';
function Fallback({ error, resetErrorBoundary }) {
return (
<div role="alert">
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Retry</button>
</div>
);
}
// resetKeys: boundary auto-resets whenever `userId` changes
<ErrorBoundary FallbackComponent={Fallback} resetKeys={[userId]}>
<UserProfile userId={userId} />
</ErrorBoundary>
Rule of thumb: Use react-error-boundary in production — it removes the need
to write class components and handles the reset logic correctly.
useErrorBoundary (from react-error-boundary) returns
{ showBoundary, resetBoundary }. Calling showBoundary(error) throws the error
up to the nearest <ErrorBoundary> as if it had been thrown during rendering.
This is the standard way to route async and event-handler errors through a
boundary, since those errors are not caught automatically.
import { useErrorBoundary } from 'react-error-boundary';
function UserList() {
const { showBoundary } = useErrorBoundary();
useEffect(() => {
fetchUsers()
.then(setUsers)
.catch((err) => {
showBoundary(err); // manually escalate to nearest boundary
});
}, []);
return <ul>{/* ... */}</ul>;
}
Rule of thumb: Use showBoundary from useErrorBoundary any time you need
to route a non-render error (async, event handler) into the boundary system.
resetKeys is an array of values passed to <ErrorBoundary>. Whenever any value
in the array changes between renders, the boundary automatically resets its
error state and re-renders its children. This enables data-driven recovery
without requiring the user to click a retry button.
Common use cases: resetting when the user navigates to a different resource
(changing userId, postId), or when a query key changes after a refetch.
// Automatically retry when the user switches to a different profile
<ErrorBoundary
FallbackComponent={ProfileError}
resetKeys={[userId]} // boundary resets whenever userId changes
onReset={() => refetch()} // also trigger a fresh network request
>
<UserProfile userId={userId} />
</ErrorBoundary>
Rule of thumb: Use resetKeys when navigating between resources — it gives
every new resource a clean slate without manual reset button clicks.
componentDidCatch is the correct hook for external logging because it runs
during the commit phase where side effects are safe. It receives both the
error and info.componentStack, which together give a useful trace for
debugging. Pass both to your monitoring SDK.
With react-error-boundary, use the onError prop instead of subclassing.
import * as Sentry from '@sentry/react';
// Option 1: hand-rolled class boundary
componentDidCatch(error, info) {
Sentry.captureException(error, {
extra: { componentStack: info.componentStack },
});
}
// Option 2: react-error-boundary — onError prop (preferred)
<ErrorBoundary
FallbackComponent={Fallback}
onError={(error, info) => {
Sentry.captureException(error, {
extra: { componentStack: info.componentStack },
});
}}
>
<App />
</ErrorBoundary>
Rule of thumb: Always log both the error and the component stack —
error.message tells you what crashed; componentStack tells you where.
<Suspense> handles loading states (a component "suspends" by throwing a
Promise); error boundaries handle error states (a component throws an Error).
The two are designed to compose: wrap a <Suspense> inside an <ErrorBoundary>
to cover both loading and failure for the same subtree.
In concurrent mode, React can retry suspended trees. If a retry results in an Error (not a Promise), the nearest error boundary catches it.
// Layered: error boundary wraps Suspense — covers both failure modes
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<Spinner />}>
{/* LazyComponent suspends while loading → shows Spinner */}
{/* If it throws an Error instead → ErrorBoundary catches it */}
<LazyComponent />
</Suspense>
</ErrorBoundary>
Rule of thumb: Wrap every <Suspense> in an <ErrorBoundary> — Suspense
only handles the "pending" state; errors still need a boundary.
Concurrent React introduces partial rendering and retries, which changes some boundary behavior:
- React may attempt to render a subtree multiple times before committing, so
getDerivedStateFromErrormight be called more than once for the same error. The method must remain pure. - With
startTransition, React can defer a failing render and keep the previous UI visible rather than immediately showing the boundary fallback, giving a smoother degradation. - In Strict Mode (development only) React double-invokes render to surface
impure code —
componentDidCatchis not double-invoked, butgetDerivedStateFromErrormay be.
// Wrap a non-urgent update in a transition:
// if it errors, React keeps showing the stale UI instead of the fallback
const [isPending, startTransition] = useTransition();
startTransition(() => {
setResourceId(newId); // error here → boundary catches, but only after transition
});
Rule of thumb: Keep getDerivedStateFromError pure — concurrent mode may
call it multiple times before a single commit.
A boundary cannot catch its own errors. If render() or any lifecycle in the
boundary class itself throws, React walks up the tree and looks for the next
ancestor error boundary. If none exists, the entire React tree is unmounted.
This means every error boundary implicitly relies on its parent being error-free — or being wrapped in another boundary. In practice, nest a minimal top-level boundary (that itself contains no complex logic) as the outermost root to catch anything a deeper boundary misses.
// A boundary that itself might fail — bad practice:
class RiskyBoundary extends React.Component {
static getDerivedStateFromError() { return { hasError: true }; }
render() {
if (this.state.hasError) {
return <ComplexFallbackThatMightAlsoThrow />; // dangerous!
}
return this.props.children;
}
}
// Keep fallback UI extremely simple — plain HTML, no data fetching:
render() {
if (this.state.hasError) {
return <p>Something went wrong.</p>; // safe, cannot throw
}
return this.props.children;
}
Rule of thumb: Keep fallback UI dead simple — it should be plain HTML or a static string, never something that fetches data or renders lazily.
The standard approach is to render a component that intentionally throws and
assert that the fallback UI appears. React Testing Library does not suppress the
console error from React's internal boundary reporting, so you should spy on
console.error and suppress it to keep test output clean.
import { render, screen } from '@testing-library/react';
import { ErrorBoundary } from 'react-error-boundary';
// A component that always throws
function Bomb() {
throw new Error('💥 test error');
}
test('renders fallback when child throws', () => {
// Suppress React's console.error noise in test output
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary fallback={<p>Boundary caught it</p>}>
<Bomb />
</ErrorBoundary>
);
expect(screen.getByText('Boundary caught it')).toBeInTheDocument();
spy.mockRestore(); // always restore after test
});
Rule of thumb: Suppress console.error during boundary tests to keep output
clean, but always restore the spy — suppressing it globally hides real issues.
Test recovery by simulating the retry action (clicking the button) and asserting that the children render successfully after the error condition is cleared. Use a stateful wrapper to toggle whether the child throws, simulating a data fix.
let shouldThrow = true;
function MaybeThrow() {
if (shouldThrow) throw new Error('temporary failure');
return <p>Content loaded</p>;
}
test('retries successfully after reset', async () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary FallbackComponent={({ resetErrorBoundary }) => (
<button onClick={() => {
shouldThrow = false; // fix the condition before reset
resetErrorBoundary(); // trigger boundary reset
}}>Retry</button>
)}>
<MaybeThrow />
</ErrorBoundary>
);
await userEvent.click(screen.getByText('Retry'));
expect(screen.getByText('Content loaded')).toBeInTheDocument();
spy.mockRestore();
});
Rule of thumb: Fix the error condition before calling resetErrorBoundary
— otherwise the boundary resets and immediately throws again.
Error boundaries protect against uncaught errors in the render cycle —
they intercept errors in render, lifecycle methods, and constructors. They
provide a declarative, component-scoped safety net.
try/catch is for imperative code — event handlers, async operations, and utility functions outside the React render cycle where boundaries cannot reach.
| Scenario | Tool |
|---|---|
| Component throws during render | Error boundary |
async fetch in useEffect |
try/catch + state |
| Button click handler throws | try/catch |
| Lazy-loaded component fails | Error boundary + Suspense |
// Render error → boundary handles it automatically
function BadRender() {
const data = JSON.parse(null); // throws during render → boundary catches
return <div>{data.name}</div>;
}
// Async error → try/catch needed
async function loadUser(id) {
try {
return await api.getUser(id);
} catch (err) {
showBoundary(err); // escalate manually via useErrorBoundary
}
}
Rule of thumb: Use boundaries for render errors, try/catch for everything
else — then bridge async failures into boundaries via showBoundary.
Yes — React will propagate an unhandled error up the tree until it finds the nearest ancestor error boundary. Multiple nested boundaries each act as independent catch points for their own subtrees.
A healthy app has multiple boundaries at different levels:
- The root boundary is the final backstop.
- Route-level boundaries isolate page failures.
- Widget-level boundaries isolate autonomous sections.
// Each boundary is independent — inner boundary failing falls up to outer
<AppBoundary> {/* outer: catches everything the inner misses */}
<Layout>
<NavBoundary> {/* isolates nav errors */}
<Navigation />
</NavBoundary>
<PageBoundary> {/* isolates current page errors */}
<MainContent>
<WidgetBoundary> {/* isolates a single widget */}
<LiveFeed />
</WidgetBoundary>
</MainContent>
</PageBoundary>
</Layout>
</AppBoundary>
Rule of thumb: Think of boundaries like try/catch nesting — the innermost
matching boundary catches the error; outer ones only fire if the inner one itself
fails or doesn't exist.
In development (react-dom/development), React re-throws errors after calling
the boundary so that the browser's DevTools overlay and the console both show the
full stack trace. This means you'll see the error in the console even when a
boundary catches it — that's intentional, not a bug.
In production (react-dom/production), the error is caught silently by the
boundary, the fallback UI is shown, and no overlay appears. Users see only the
fallback; developers see nothing unless logging (Sentry, etc.) is set up.
// Dev: React error overlay appears + console.error fires even with a boundary.
// This tells developers the error happened; the boundary still works in prod.
// A common mistake: developers test boundaries in dev, see the overlay,
// and think the boundary isn't working — it is; the overlay is just dev behavior.
componentDidCatch(error, info) {
// This is your ONLY visibility into boundary-caught errors in production
logToMonitoringService(error, info);
}
Rule of thumb: The dev error overlay does not mean the boundary failed —
React shows it deliberately; always test production behavior with npm run build.
More Patterns interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.