React

Error Boundaries

14 Questions

Error boundaries are class components that catch JavaScript errors thrown during rendering, in lifecycle methods, or in constructors anywhere in their child tree, preventing those errors from crashing the entire application. Without them, a single rendering error unmounts the whole React tree, leaving users with a blank page. When an error is caught, the boundary replaces its children with a fallback UI — a friendly message, a retry button, or a skeleton. Every error boundary must implement at least getDerivedStateFromError to show the fallback and optionally componentDidCatch for logging.

class ErrorBoundary extends React.Component {
  state = { hasError: false };
  static getDerivedStateFromError(error) { return { hasError: true }; }
  componentDidCatch(error, info) { logError(error, info); }
  render() {
    if (this.state.hasError) return <h1>Something went wrong.</h1>;
    return this.props.children;
  }
}

Why it matters: Without error boundaries, a single render error crashes the whole React tree — users see a blank page.

Real applications: Wrapping third-party widgets, dashboard panels, or route pages so one broken component doesn't break everything else.

Common mistakes: Forgetting to implement both getDerivedStateFromError and componentDidCatch — the former triggers the fallback, the latter logs the error.

Error boundaries cannot be written as function components because there are no hook equivalents for getDerivedStateFromError or componentDidCatch — they require class lifecycle methods. This is one of the few remaining reasons to write a class component in React today. For modern codebases, the react-error-boundary package wraps the class internally and exposes a clean, hook-friendly API with built-in features like resetErrorBoundary and onReset. Most teams install this package rather than write error boundary classes directly.

Why it matters: Knowing this limitation stops you from wasting time trying to write a hook-based error boundary from scratch.

Real applications: Most projects use the react-error-boundary package — it wraps the class internally and exposes a clean hook-friendly API.

Common mistakes: Trying to replicate getDerivedStateFromError with useState inside a function component — it doesn't work because hooks can't catch render errors.

Error boundaries only intercept errors that occur during React's render cycle. Several common error scenarios fall completely outside their reach and require separate try/catch handling strategies. Understanding these gaps is essential for building a complete error handling strategy in your app.

  • Event handlers (use try/catch instead)
  • Asynchronous code (setTimeout, fetch callbacks)
  • Server-side rendering
  • Errors thrown in the error boundary itself

Why it matters: Knowing what error boundaries can't catch tells you where to add try/catch in async code or event handlers.

Real applications: A button click that calls a broken async function won't be caught — you need try/catch inside the handler and setState to show the error.

Common mistakes: Assuming error boundaries protect against everything — async errors and event handler errors need separate handling.

The react-error-boundary package is the standard way to use error boundaries in modern React apps — it wraps the required class component and provides a clean hook-based experience. It passes the caught error and a resetErrorBoundary function to your fallback component, letting you build retry UIs with minimal boilerplate. You can provide onError for logging and onReset to undo state when the user retries.

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>
  );
}

<ErrorBoundary FallbackComponent={Fallback}>
  <MyComponent />
</ErrorBoundary>

Why it matters: The library handles the boilerplate class component for you and adds useful features like resetErrorBoundary out of the box.

Real applications: Use it anywhere in a React app — wrap routes, feature panels, or any component you want to isolate from crashes.

Common mistakes: Not defining a FallbackComponent — without it the library still catches errors but renders nothing, which is confusing for users.

getDerivedStateFromError is a static lifecycle method called synchronously during React's render phase when a descendant throws. It receives the error and must return an object to merge into the boundary's state, which is then used to trigger the fallback render. Because it runs during rendering, it must stay completely pure — no side effects, no API calls. Return only the minimal state needed to switch from normal to fallback rendering.

Why it matters: This is what switches the error boundary from normal mode to error mode — returning { hasError: true } triggers the fallback render.

Real applications: Return minimal state here — just a flag or the error itself; keep logging and side effects for componentDidCatch.

Common mistakes: Trying to call setState or make API calls inside this method — it must stay pure since it runs during rendering.

componentDidCatch is called after the commit phase, once the error boundary's fallback has been committed to the DOM. It receives the error and an info object containing the componentStack trace, which shows exactly which component threw. This is the correct place to send error reports to monitoring services like Sentry, Datadog, or LogRocket. Unlike getDerivedStateFromError, this method can safely perform side effects.

Why it matters: This is the right place to send error reports to Sentry, Datadog, or any other monitoring tool.

Real applications: Log the component stack alongside the error message so developers can trace exactly which component tree caused the crash.

Common mistakes: Updating state inside componentDidCatch — use getDerivedStateFromError to update state; this method is only for side effects like logging.

Placing multiple error boundaries at different levels of the tree provides much finer error isolation than a single root-level boundary. A top-level boundary catches everything but replaces the whole page; route-level boundaries isolate page crashes; feature-level boundaries around widgets and panels limit the blast radius to a single section. The goal is that any one broken component shows a localized error message while the rest of the app continues working normally.

Why it matters: One error boundary at the root catches everything but replaces the whole page — smaller boundaries limit the blast radius of a crash.

Real applications: A news feed widget can crash and show a "couldn't load feed" message without affecting the navigation or the rest of the page.

Common mistakes: Only placing one error boundary at the app root — granular placement gives users a much better experience when isolated sections fail.

Once an error boundary catches an error, it stays in error mode until explicitly reset. The two main recovery mechanisms are: changing the key prop on the ErrorBoundary (which unmounts and fully remounts it) or calling the resetErrorBoundary function provided by react-error-boundary to the fallback. Providing a retry path is critical so users can recover without refreshing the entire browser tab.

<ErrorBoundary key={resetKey} FallbackComponent={Fallback}>
  <FailingComponent />
</ErrorBoundary>

Why it matters: Without a recovery path, users are stuck with the error screen until they refresh the whole page.

Real applications: A "Try again" button that increments a reset key remounts the failed component cleanly, letting users retry without losing the rest of the page state.

Common mistakes: Not resetting the error boundary state when the key changes — using the key prop is the simplest way to force a fresh mount.

Error boundaries have no effect on event handlers — handlers run outside React's render cycle, so errors thrown inside them bypass error boundaries entirely. To handle these, wrap risky code in a try/catch block, catch the error, and store it in component state to display a message to the user. This is a complementary, required strategy alongside error boundaries for complete error handling coverage.

function MyButton() {
  const [error, setError] = useState(null);
  const handleClick = () => {
    try { riskyOperation(); }
    catch (e) { setError(e); }
  };
  if (error) return <p>Error: {error.message}</p>;
  return <button onClick={handleClick}>Click</button>;
}

Why it matters: Event handlers run outside React's render cycle, so error boundaries can't catch errors thrown there — you must handle them yourself.

Real applications: Any button that calls an API or runs risky logic needs try/catch inside the handler and state to display the error message.

Common mistakes: Relying on an error boundary to catch event handler errors — the component stays mounted and the boundary never triggers.

Error boundaries protect the render layer — they catch errors in component functions, class lifecycle methods, and constructors. try/catch protects the logic layer — event handlers, async/await calls, setTimeout callbacks, and Promises. A complete error handling strategy uses both: neither alone is sufficient. Relying only on error boundaries misses all async errors; relying only on try/catch misses all render errors.

Why it matters: Neither alone is enough — you need error boundaries for render errors and try/catch for everything else.

Real applications: Use error boundaries on route components and try/catch inside event handlers and async functions throughout the app.

Common mistakes: Using only try/catch and missing render errors, or using only error boundaries and missing async/event handler errors.

Production error monitoring is essential for catching bugs that users experience but never report. In componentDidCatch, send the error and component stack to services like Sentry, Datadog, or LogRocket. The info.componentStack shows the exact React component chain that caused the crash, which is invaluable for reproducing production bugs. Always log in componentDidCatch, not getDerivedStateFromError, because logging is a side effect and must not run during the render phase.

componentDidCatch(error, info) {
  // info.componentStack shows where the error came from
  Sentry.captureException(error, {
    extra: { componentStack: info.componentStack }
  });
  logToMyAPI({ error: error.message, stack: info.componentStack });
}

Always log in componentDidCatch, not getDerivedStateFromError, because side effects should not happen in lifecycle methods that derive state.

Why it matters: Error monitoring services give you real-time visibility into which components are crashing in production and for which users.

Real applications: Send the component stack to Sentry so developers can reproduce the crash without needing to manually trace through the component tree.

Common mistakes: Logging inside getDerivedStateFromError — it runs during rendering and must stay pure, so API calls there can cause unpredictable behavior.

Testing error boundaries requires rendering a component that explicitly throws and asserting that the fallback UI appears. React Testing Library is the standard tool for this. You must suppress console.error during the test because React logs caught errors to the console even when a boundary handles them, polluting CI output. Always restore the mock afterward to avoid affecting other tests in the suite.

function BrokenComponent() {
  throw new Error('Boom!');
}

test('renders fallback on error', () => {
  // Suppress console.error for cleaner test output
  jest.spyOn(console, 'error').mockImplementation(() => {});

  render(
    <ErrorBoundary fallback={<p>Something went wrong</p>}>
      <BrokenComponent />
    </ErrorBoundary>
  );

  expect(screen.getByText('Something went wrong')).toBeInTheDocument();
  console.error.mockRestore();
});

Without suppressing console.error, React will print the error to the test output even when the error boundary handles it.

Why it matters: Testing the fallback confirms the boundary actually works — without a test you won't know until it breaks in production.

Real applications: Use react-testing-library to render the boundary wrapping a component that always throws, then assert on the fallback text.

Common mistakes: Not mocking console.error — the test passes but produces noisy error output that makes CI logs hard to read.

Understanding where each error originates determines how to handle it. Render errors occur inside component functions, lifecycle methods, or constructors and are caught by error boundaries because they happen synchronously during React's render phase. Async errors occur in setTimeout, Promise callbacks, and async/await — they happen outside the render cycle and escape error boundaries entirely. A robust app must handle both explicitly.

// ❌ Error boundary WON'T catch this
function MyButton() {
  const handleClick = async () => {
    const data = await fetchData(); // throws — not caught by boundary
  };
}

// ✅ Handle async errors yourself
const handleClick = async () => {
  try {
    const data = await fetchData();
  } catch (e) {
    setError(e.message);
  }
};

Why it matters: Mixing up these two types leads to gaps — you think your error boundary covers everything but async errors slip through silently.

Real applications: Wrap fetch calls, socket handlers, and timer callbacks in try/catch; use error boundaries for component render failures.

Common mistakes: Writing async code inside a component body without try/catch, assuming the error boundary will catch the unhandled promise rejection.

A reusable error boundary with a retry button gives users a self-service recovery path without requiring a full page reload. The react-error-boundary package makes this straightforward: pass a FallbackComponent that receives the error and resetErrorBoundary, then wire the retry button to call resetErrorBoundary. Provide an onReset callback to re-fetch data or clear any stale state after the reset, ensuring the component starts fresh.

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

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div role="alert">
      <p><strong>Error:</strong> {error.message}</p>
      <button onClick={resetErrorBoundary}>Try Again</button>
    </div>
  );
}

<ErrorBoundary
  FallbackComponent={ErrorFallback}
  onReset={() => refetch()} // optional: re-fetch on reset
>
  <DataComponent />
</ErrorBoundary>

resetErrorBoundary clears the error state and re-renders the children, giving the user a chance to recover without a full page reload.

Why it matters: A retry button gives users a self-service recovery option — fewer support requests and no need to lose their whole page context.

Real applications: Data fetching components where a network blip caused the error — the user retries and the fetch succeeds without a full reload.

Common mistakes: Not using onReset to clear the data that caused the crash — resetting without clearing stale state can cause the same error immediately.