React2 min read

Error boundaries do not catch the errors you expect

Event handlers, async code, and the server are all outside an error boundary. Here is what it does cover, and how to handle the rest without a white screen.

You add an error boundary, feel safe, and then a failed save in a click handler still takes the page down to a blank screen.

Error boundaries cover less than most people assume. Knowing the boundary of the boundary is the whole trick.

What it catches

Errors thrown while React is rendering, in the tree below it:

  • during a component's render
  • in a lifecycle method
  • in a constructor

That is it.

What it does not catch

  • Event handlers. onClick, onSubmit, onChange. These run outside render.
  • Async code. Anything after an await, inside setTimeout, or in a promise callback.
  • The boundary itself. An error in the boundary's own render goes to the boundary above it.
  • Server rendering. On the server you need the framework's error handling.

The reason is consistent: React can unmount a broken subtree during render because it controls that moment. It has no idea when your click handler throws three seconds later.

The boundary

React still has no hook version, so this is a class. Write it once:

class ErrorBoundary extends React.Component {
  state = { error: null };

  static getDerivedStateFromError(error) {
    return { error };
  }

  componentDidCatch(error, info) {
    logToService(error, info.componentStack);
  }

  render() {
    if (this.state.error) {
      return this.props.fallback ?? <p>Something went wrong.</p>;
    }
    return this.props.children;
  }
}

getDerivedStateFromError handles the display. componentDidCatch is where logging goes, and info.componentStack tells you which component threw, which is the part your logs usually lack.

The library react-error-boundary gives you the same thing plus a reset API, and is worth the dependency if you need retry.

Where to put them

Not one at the root. A single boundary means any error anywhere takes out the whole page.

Put them at the seams where a broken piece can be shown as broken while the rest keeps working:

<Layout>
  <ErrorBoundary fallback={<ChartError />}>
    <RevenueChart />
  </ErrorBoundary>

  <ErrorBoundary fallback={<FeedError />}>
    <ActivityFeed />
  </ErrorBoundary>
</Layout>

A failing chart should not remove the navigation.

Handling the parts it misses

Event handlers: try and catch, then state.

const [error, setError] = useState(null);

const save = async () => {
  try {
    await api.save(draft);
  } catch (err) {
    setError(err);
  }
};

Or push it into the boundary on purpose, if a failure really is fatal for that subtree. Throwing during the next render is what makes it visible to React:

const [error, setError] = useState(null);
if (error) throw error;

Unhandled rejections at the window level, as a net for what you missed:

window.addEventListener("unhandledrejection", (event) => {
  logToService(event.reason);
});

Suspense is the other half

Suspense handles the waiting state, error boundaries handle the failure state. They are separate on purpose, and a data fetching component usually needs both:

<ErrorBoundary fallback={<ProfileError />}>
  <Suspense fallback={<ProfileSkeleton />}>
    <Profile userId={id} />
  </Suspense>
</ErrorBoundary>

Order matters: the boundary goes outside, so a failure replaces the whole block instead of leaving a spinner running forever.

One habit that pays

Give every fallback a way forward: a retry button, a link back, or a support reference. "Something went wrong" with no action is only marginally better than the blank screen it replaced.

Written by Rahul Mourya, full stack engineer. Spotted a mistake or want the follow-up post? Email me.

All posts
  • React2 min read

    You pushed to the array and nothing rendered

    React compares state with Object.is, so changing an array or object in place looks like no change at all. Here are the copy patterns, including the one for nested data.

    • state
    • immutability
    • rendering
  • React2 min read

    One context is re-rendering your whole app

    Context has no selectors. Every consumer re-renders when the value changes, even the ones using a field that did not. Splitting the context fixes most of it.

    • context
    • performance
    • state