| |

React 78 ⚛️ Catching Rendering Errors with Error Boundaries

⏱️ Estimated read: 14 min | |

React’s rendering process is a synchronous, recursive operation. When a component throws during render, React cannot simply catch the error and continue—the component tree may be in an inconsistent state, with some components mounted and others not, some hooks already called and others not. In React 15 and earlier, this inconsistency would corrupt React’s internal state and produce cryptic errors on subsequent renders . Error boundaries were introduced in React 16 to solve this: they are the only mechanism that can catch a rendering error, log it, and display a fallback UI instead of letting the entire application crash .

The mechanism is precise and deliberately limited. An error boundary is a class component that implements either static getDerivedStateFromError() or componentDidCatch(). When a descendant throws during render, lifecycle method, or constructor, React finds the nearest error boundary above the crashing component, calls its getDerivedStateFromError to update state, then calls componentDidCatch for logging, and renders the fallback instead of the crashed subtree . The boundary does not catch errors in event handlers, asynchronous callbacks, server-side rendering, or its own render method—those are outside the rendering phase that the boundary can intercept .

Key point: Error boundaries are class components that implement static getDerivedStateFromError() to render a fallback and componentDidCatch() to log the error; they catch errors during rendering, in lifecycle methods, and in constructors of their descendant tree, but they do not catch errors in event handlers, async callbacks, server rendering, or the boundary itself.


Why error boundaries are limited to rendering errors

The rendering phase problem. React renders components synchronously. When render() throws, React is mid-traversal—some components have been rendered, some have not, and hooks may have been partially called. There is no safe point to resume. The only correct response is to discard the partially-rendered subtree and render something else in its place . This is what the boundary does: it catches the error, sets hasError to true, and re-renders its fallback instead of its children.

The event handler problem. An event handler runs after rendering is complete. When onClick throws, React has already committed the UI and knows exactly what is on screen. There is no inconsistent tree to recover from. React does not need a boundary to recover, and the boundary does not fire . The correct tool for event handler errors is a regular try/catch inside the handler itself, which can set local state and show an inline error message without unmounting the component .

The async problem. A promise rejection or a setTimeout callback runs outside React’s rendering cycle. React has no way to associate the error with a component, and the boundary has no mechanism to catch it. The workaround is to bridge the error into the rendering phase: catch it in the async callback, call a state setter that triggers a re-render, and throw the error during that render so the boundary catches it . Libraries like react-error-boundary provide a useErrorBoundary hook that does exactly this: showBoundary(error) schedules a state update that throws during render, which the boundary then catches .

The boundary-self problem. An error boundary cannot catch its own errors. If the fallback UI itself throws, the error propagates to the nearest boundary above it in the tree . This is the same behavior as a JavaScript catch block: a catch cannot catch an error thrown inside itself.


a. Creating an error boundary with getDerivedStateFromError and componentDidCatch

The class component has two lifecycle methods that together make it a boundary. static getDerivedStateFromError(error) is called during the render phase, receives the error, and returns the state update that switches to the fallback. It must be pure—no side effects, no logging, just the state update . componentDidCatch(error, errorInfo) is called after the render phase, receives the error and a componentStack string identifying where the error occurred, and is where logging to an external service belongs .

import { Component } from 'react'

class ErrorBoundary extends Component {
  // Initial state: no error, render children normally
  state = { hasError: false }

  // Called during render when a descendant throws
  // must be pure; returns the state update
  static getDerivedStateFromError(error) {
    // Switch to the fallback UI on the next render
    return { hasError: true }
  }

  // Called after the error has been committed
  // safe place for side effects: logging, reporting
  componentDidCatch(error, errorInfo) {
    // error is the thrown Error
    // errorInfo.componentStack identifies the component path
    logErrorToService(error, errorInfo.componentStack)
  }

  render() {
    // If an error was caught, render the fallback
    if (this.state.hasError) {
      // fallback can be a prop, a default UI, or a render function
      return this.props.fallback ?? <h1>Something went wrong.</h1>
    }
    // Otherwise render the children normally
    return this.props.children
  }
}

The componentStack is the property that makes the boundary useful for debugging. It shows the path through the component tree that led to the crash, which is often more actionable than the JavaScript stack trace because it identifies which component in the tree was responsible . In development, React also prints the component stack to the console for every error, even if the boundary swallows it .


b. Placement: application, route, and component boundaries

The placement of boundaries determines the blast radius of a crash. A single boundary at the root means any error anywhere unmounts the entire application and shows a full-page fallback. A boundary around every component means excessive boilerplate and a fragmented error experience. The correct approach is to place boundaries at meaningful UI sections—units that make sense to fail as a group .

Application-level boundary. Wraps the root. It catches catastrophic failures and shows a full-page fallback with a reload option. This is the last line of defence, not the primary error-handling strategy .

Route-level boundary. Wraps each route’s content. A crash in /dashboard shows a fallback in the dashboard area, while the navigation and the rest of the shell remain interactive. The user can navigate to /settings without reloading the page . The resetKeys prop is the mechanism that makes this work: when the route path changes, the boundary resets and re-renders its children, clearing the error state .

Component-level boundary. Wraps independent widgets: a chart, a feed, a sidebar. A crash in the chart shows a small inline fallback in the chart’s area, and the rest of the page continues working . This is the granularity that prevents one broken feature from taking down the entire view.

function Dashboard() {
  return (
    <div className="layout">
      {/* Sidebar error does not crash the main content */}
      <ErrorBoundary fallback={<SidebarFallback />}>
        <Sidebar />
      </ErrorBoundary>

      {/* Chart error does not crash the feed */}
      <main>
        <ErrorBoundary fallback={<ChartFallback />}>
          <RevenueChart />
        </ErrorBoundary>

        <ErrorBoundary fallback={<FeedFallback />}>
          <ActivityFeed />
        </ErrorBoundary>
      </main>
    </div>
  )
}

c. Recovery: resetting the boundary and retrying

A boundary that shows a fallback without a recovery path is a dead end. The user sees “Something went wrong” and has no way to try again. The recovery mechanism is to reset the boundary’s state, which re-renders the children and gives them another chance .

The react-error-boundary library provides a clean API for this. Its ErrorBoundary component accepts a FallbackComponent that receives error and resetErrorBoundary. Calling resetErrorBoundary() sets hasError back to false, and React re-renders the children . The resetKeys prop automates the reset: when any key in the array changes, the boundary resets. This is ideal for route parameters, search queries, and any state that identifies a different data context .

import { ErrorBoundary } from 'react-error-boundary'

// The fallback receives the error and the reset function
function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <p>Something went wrong:</p>
      <pre>{error.message}</pre>
      {/* Clicking this resets the boundary and retries the render */}
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  )
}

function UserProfile({ userId }) {
  return (
    // resetKeys: when userId changes, the boundary auto-resets
    // this is correct because a different user is a different context
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      resetKeys={[userId]}
      onReset={() => {
        // optional: clear any state that caused the error
      }}
    >
      <ProfileDetails userId={userId} />
    </ErrorBoundary>
  )
}

The onReset callback runs when the boundary resets, which is the place to clear any application state that contributed to the error—a failed query cache, a stale filter, a corrupted local state . Without it, the retry may immediately recreate the same error.

For asynchronous errors that originate outside the rendering phase, the useErrorBoundary hook provides the bridge. It returns a showBoundary function that schedules a state update which throws during the next render, allowing the boundary to catch an error that occurred in a promise rejection or a callback .

import { useErrorBoundary } from 'react-error-boundary'
import { useEffect } from 'react'

function DataLoader({ url }) {
  // showBoundary re-throws an async error during render
  // so the nearest ErrorBoundary can catch it
  const { showBoundary } = useErrorBoundary()

  useEffect(() => {
    fetch(url)
      .then((res) => res.json())
      // catch the rejection and route it to the boundary
      .catch(showBoundary)
  }, [url, showBoundary])

  return <div>Loading...</div>
}

Complete Example Session

The following session builds a dashboard with an application-level boundary, a route-level boundary that resets on navigation, and a component-level boundary that isolates a fragile chart.

// ============================================
// PART 1: THE BASE ERROR BOUNDARY
// ============================================
import { Component } from 'react'

class ErrorBoundary extends Component {
  // Track whether a descendant has thrown
  state = { hasError: false }

  // Called during render; must be pure
  static getDerivedStateFromError() {
    // Switch to fallback on the next render
    return { hasError: true }
  }

  // Called after commit; safe for logging
  componentDidCatch(error, errorInfo) {
    // Report the error and the component stack
    logToService(error, errorInfo.componentStack)
  }

  render() {
    // Render the fallback if an error was caught
    if (this.state.hasError) {
      // Allow the parent to provide a custom fallback
      return this.props.fallback
    }
    // Otherwise render the children
    return this.props.children
  }
}

// ============================================
// PART 2: A COMPONENT THAT THROWS
// ============================================
function FragileChart({ data }) {
  // Simulate a render error when data is missing
  if (!data) {
    throw new Error('Chart data is missing')
  }
  return <div>Chart with {data.length} points</div>
}

// ============================================
// PART 3: THE DASHBOARD WITH BOUNDARIES
// ============================================
function Dashboard({ route, chartData }) {
  return (
    <div>
      {/* Application-level fallback for catastrophic errors */}
      <ErrorBoundary fallback={<h1>Application error. Reload.</h1>}>
        {/* Route content is isolated */}
        <ErrorBoundary fallback={<p>This page failed to load.</p>}>
          <RouteContent route={route} />
        </ErrorBoundary>

        {/* The chart is isolated so its failure stays local */}
        <ErrorBoundary fallback={<p>Chart unavailable.</p>}>
          <FragileChart data={chartData} />
        </ErrorBoundary>
      </ErrorBoundary>
    </div>
  )
}

The three parts cover the base boundary with both lifecycle methods, a component that throws during render, and the nested placement that isolates the chart from the route content and the application shell. What the session demonstrates is that a rendering error in FragileChart unmounts only the chart’s subtree—the route content and the navigation remain interactive—and the error is logged with its component stack.


Quick Reference

Error Boundary API

MethodPhasePurpose
static getDerivedStateFromError(error)RenderUpdate state to show fallback
componentDidCatch(error, info)CommitLog error and component stack

What Boundaries Catch

SourceCaught?
Render methodYes
Lifecycle methodsYes
ConstructorsYes
Event handlersNo
setTimeout / async callbacksNo
Server-side renderingNo
The boundary itselfNo

Placement Strategy

LevelScopeUse Case
ApplicationEntire appLast resort, full-page fallback
RoutePer routeIsolate page crashes, reset on navigation
ComponentWidget / sectionIsolate charts, feeds, sidebars

Recovery Mechanisms

MechanismAPIEffect
Manual resetresetErrorBoundary()Re-render children
Auto resetresetKeys={[dep]}Reset when dep changes
Async bridgeshowBoundary(error)Route async error to boundary

Best Practices

✅ Do This:

// Use getDerivedStateFromError for state, componentDidCatch for logging
static getDerivedStateFromError() { return { hasError: true } }
componentDidCatch(error, info) { log(error, info.componentStack) }

// Place boundaries at meaningful sections
<ErrorBoundary fallback={<ChartFallback />}><Chart /></ErrorBoundary>

// Use resetKeys for context changes
<ErrorBoundary resetKeys={[userId]} FallbackComponent={Fallback}>

// Bridge async errors with showBoundary
fetchData().catch(showBoundary)

❌ Don’t Do This:

// Use try/catch around JSX — it won't catch render errors
try { return <Child /> } catch (e) {} // useless

// Use an error boundary for event handler errors
onClick={() => { throw new Error() }} // boundary won't catch

// Show stack traces in production
<pre>{error.stack}</pre> // leak internal paths

// Place a boundary around every component
<ErrorBoundary><Button /></ErrorBoundary> // excessive

Common Pitfalls

PitfallWhy It HappensFix
Boundary does not catch event errorEvents are outside render phaseUse try/catch in handler
Boundary does not catch async errorPromises are outside render phaseBridge with showBoundary
Boundary does not catch its own errorBoundary only catches descendantsAdd a parent boundary
Blank screen after errorNo boundary at the rootAdd application-level boundary
Retry recreates the errorState that caused it is not resetClear state in onReset
try/catch around JSXConfused with renderingUse a boundary

Real-World Examples

1. Basic boundary

class ErrorBoundary extends Component {
  state = { hasError: false }
  static getDerivedStateFromError() { return { hasError: true } }
  render() { return this.state.hasError ? <Fallback /> : this.props.children }
}

2. Boundary with logging

componentDidCatch(error, info) { logToSentry(error, info.componentStack) }

3. Fallback with retry

<ErrorBoundary fallbackRender={({ error, reset }) => (
  <button onClick={reset}>Retry</button>
)}>

4. Route boundary with reset key

<ErrorBoundary resetKeys={[location.pathname]} FallbackComponent={RouteFallback}>

5. Widget isolation

<ErrorBoundary fallback={<p>Widget failed</p>}><Widget /></ErrorBoundary>

6. Async bridge

const { showBoundary } = useErrorBoundary()
useEffect(() => { fetch().catch(showBoundary) }, [])

7. React 19 transition error

startTransition(() => { setPage(path) }) // errors bubble to boundary

8. Development-only stack

{import.meta.env.DEV && <pre>{error.stack}</pre>}

9. Nested boundaries

<ErrorBoundary><ErrorBoundary><Chart /></ErrorBoundary></ErrorBoundary>

10. Test a boundary

render(<ErrorBoundary fallback={<p>Error</p>}><Thrower /></ErrorBoundary>)
expect(screen.getByText('Error')).toBeInTheDocument()

Visual

What the Boundary Catches

┌──────────────────────────────────────────────┐
│  ErrorBoundary                               │
│  ┌────────────────────────────────────────┐  │
│  │  render()      ✅ caught                │  │
│  │  constructor   ✅ caught                │  │
│  │  lifecycle     ✅ caught                │  │
│  │  onClick       ❌ not caught            │  │
│  │  setTimeout    ❌ not caught            │  │
│  │  fetch().catch ❌ not caught            │  │
│  └────────────────────────────────────────┘  │
│                                              │
│  The boundary only sees errors in its        │
│  descendants' rendering phase.               │
└──────────────────────────────────────────────┘

Boundary Lifecycle

┌──────────────────────────────────────────────┐
│  Descendant throws during render             │
│         │                                    │
│         ▼                                    │
│  React finds nearest ErrorBoundary           │
│         │                                    │
│         ▼                                    │
│  getDerivedStateFromError(error)             │
│  → returns { hasError: true }               │
│         │                                    │
│         ▼                                    │
│  componentDidCatch(error, info)              │
│  → logs to service                           │
│         │                                    │
│         ▼                                    │
│  render() returns fallback                   │
└──────────────────────────────────────────────┘

Placement Granularity

┌──────────────────────────────────────────────┐
│  APPLICATION BOUNDARY (last resort)          │
│  ┌────────────────────────────────────────┐  │
│  │  ROUTE BOUNDARY                        │  │
│  │  ┌──────────────────────────────────┐  │  │
│  │  │  WIDGET BOUNDARY                 │  │  │
│  │  │  Chart crashes → only chart area │  │  │
│  │  │  shows fallback                  │  │  │
│  │  └──────────────────────────────────┘  │  │
│  └────────────────────────────────────────┘  │
└──────────────────────────────────────────────┘

Async Error Bridge

┌──────────────────────────────────────────────┐
│  fetch() rejects                             │
│         │                                    │
│         ▼                                    │
│  .catch(showBoundary)                        │
│         │                                    │
│         ▼                                    │
│  showBoundary sets state that throws         │
│         │                                    │
│         ▼                                    │
│  During render, error is thrown              │
│         │                                    │
│         ▼                                    │
│  ErrorBoundary catches it                    │
└──────────────────────────────────────────────┘

Summary

ItemValue
IntroducedReact 16
ImplementationClass component
State methodstatic getDerivedStateFromError
Logging methodcomponentDidCatch(error, info)
CatchesRender, lifecycle, constructor
Does not catchEvents, async, SSR, self
Uncaught errorUnmounts entire tree
RecoveryReset state or resetKeys
Libraryreact-error-boundary

Key takeaways:

  • Error boundaries are the only mechanism that catches rendering errors. try/catch around JSX does not work because the error is thrown during React’s render traversal, not during the function call that returns the JSX .
  • The boundary has two methods with distinct roles. getDerivedStateFromError updates state during render and must be pure. componentDidCatch runs after commit and is where logging belongs .
  • Boundaries do not catch event handler errors. Events run after rendering, when React already knows the UI is consistent. Use try/catch inside the handler for those .
  • Boundaries do not catch async errors. A promise rejection or setTimeout callback is outside the render phase. Bridge it with showBoundary or a state update that re-throws during render .
  • Placement determines the blast radius. An application-level boundary is the last resort. Route-level boundaries isolate pages. Widget-level boundaries isolate charts and feeds so a single failure does not take down the rest of the page .
  • A retry needs a reset. The boundary’s state must be cleared, and any application state that caused the error must be cleared with it. resetKeys automates this for context changes like route parameters .
  • Uncaught rendering errors unmount the entire tree. React made this choice deliberately: a corrupted UI is worse than no UI. The boundary is what prevents the corruption from reaching the user .

Remember: An error boundary is not a try/catch for components. It is a rendering-phase safety net. It catches the errors that occur while React is building the tree—in render, in lifecycle methods, in constructors—and replaces the crashed subtree with a fallback. It does not catch the errors that occur after the tree is built: event handlers, timers, promises. Those require their own handling, and the bridge to route them into the boundary is a state update that throws during render. The placement of the boundary is a design decision: too high, and one error takes down the whole app; too low, and the boilerplate outweighs the benefit. The right granularity is the UI section that makes sense to fail as a unit—the route, the widget, the panel—with an application-level boundary as the final safety net.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!