← Back to Guides
8 min readIntermediate
Share

Error Boundaries and Graceful Failure in React

One unhandled render error shouldn't blank your entire app. Build error boundaries, async error handling, and fallback UIs that fail in a way users can recover from.

Error Boundaries and Graceful Failure in React

The default failure mode of a React app is brutal: one error thrown during render, anywhere in the tree, and React unmounts the entire application. The user goes from a working page to a blank white screen with no explanation. AI-generated UIs are especially prone to this — they tend to assume data is always present and shaped exactly as expected.

This guide covers how to contain failures so a broken component degrades to a small fallback instead of taking down the whole page, and how to handle the async errors that boundaries can't catch.

What an error boundary does

An error boundary is a component that catches JavaScript errors thrown during rendering of its children, logs them, and shows a fallback UI instead of crashing upward. Think of it as a try/catch for a subtree of your component tree.

There's one catch: as of today, error boundaries must be class components. There's no built-in hook equivalent, because the lifecycle methods involved (getDerivedStateFromError, componentDidCatch) have no hook form. You write the class once and never touch it again.

"use client";
import { Component, type ReactNode } from "react";

interface Props {
  children: ReactNode;
  fallback?: (error: Error, reset: () => void) => ReactNode;
}
interface State {
  error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
  state: State = { error: null };

  // Called during render when a child throws — sets fallback state.
  static getDerivedStateFromError(error: Error): State {
    return { error };
  }

  // Called after the error — the place for logging / reporting.
  componentDidCatch(error: Error, info: { componentStack: string }) {
    console.error("Caught by boundary:", error, info.componentStack);
    // reportToService(error, info) — Sentry, etc.
  }

  reset = () => this.setState({ error: null });

  render() {
    if (this.state.error) {
      if (this.props.fallback) {
        return this.props.fallback(this.state.error, this.reset);
      }
      return (
        <div className="rounded-xl border border-red-500/40 bg-red-500/10 p-4 text-red-200">
          <p className="font-medium">Something went wrong.</p>
          <button onClick={this.reset} className="mt-2 text-sm underline">
            Try again
          </button>
        </div>
      );
    }
    return this.props.children;
  }
}

Two methods do the work. getDerivedStateFromError runs during the render phase and flips the boundary into its fallback state. componentDidCatch runs afterward and is where you log or report. The reset lets the user retry without a full page reload.

Place boundaries by blast radius

The instinct is to wrap the whole app in one boundary. Don't stop there — that just turns a white screen into a slightly nicer white screen. The real win is wrapping independent regions so a failure in one doesn't erase the others.

function Dashboard() {
  return (
    <div className="grid grid-cols-2 gap-4">
      {/* Each widget fails independently — one bad
          API response doesn't blank the other three. */}
      <ErrorBoundary fallback={() => <WidgetError name="Revenue" />}>
        <RevenueWidget />
      </ErrorBoundary>
      <ErrorBoundary fallback={() => <WidgetError name="Traffic" />}>
        <TrafficWidget />
      </ErrorBoundary>
      <ErrorBoundary fallback={() => <WidgetError name="Errors" />}>
        <ErrorRateWidget />
      </ErrorBoundary>
    </div>
  );
}

A good rule: put a boundary around anything that renders data from a separate source. Each one is a firewall. The page survives even when one widget's data is malformed.

It's also worth keeping one top-level boundary as a last resort — so even a bug in your layout shows something recoverable rather than nothing.

What boundaries do NOT catch

This is the part that trips people up. Error boundaries only catch errors thrown during render (and in lifecycle methods and constructors of their children). They do not catch:

  • Errors in event handlers (onClick, onSubmit, …)
  • Errors in async code (setTimeout, promises, fetch().then())
  • Errors thrown in the boundary's own render
  • Errors during server-side rendering (handle those separately)

The reason: by the time an event handler or a .then() callback runs, React is no longer in a render pass, so there's nothing for the boundary to intercept. Most real-world failures — a failed API call, a rejected promise — fall into this category. You handle them with ordinary try/catch and explicit error state.

Handling async errors explicitly

For data fetching, model the request as a small state machine: loading → either data or error. Never assume the success path.

function useAsync<T>(fn: () => Promise<T>, deps: unknown[]) {
  const [state, setState] = useState<{
    status: "loading" | "ok" | "error";
    data?: T;
    error?: Error;
  }>({ status: "loading" });

  useEffect(() => {
    let cancelled = false;
    setState({ status: "loading" });

    fn()
      .then((data) => {
        if (!cancelled) setState({ status: "ok", data });
      })
      .catch((error) => {
        // This .catch is essential — a boundary will NOT see this.
        if (!cancelled) setState({ status: "error", error });
      });

    return () => {
      cancelled = true; // ignore the result if the component unmounted
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);

  return state;
}

The cancelled flag prevents the classic "set state on an unmounted component" warning when a request resolves after the user navigates away. And the explicit error status means the consuming component has to decide what to render when things fail:

function Profile({ id }: { id: string }) {
  const result = useAsync(() => fetchUser(id), [id]);

  if (result.status === "loading") return <Spinner />;
  if (result.status === "error") {
    return (
      <ErrorState
        message="Couldn't load this profile."
        onRetry={() => location.reload()}
      />
    );
  }
  return <ProfileCard user={result.data!} />;
}

Designing the fallback

A good fallback does three things: tells the user what failed (specifically — "Couldn't load comments," not "Error"), gives them a way to recover (retry, reload, go back), and never exposes a raw stack trace or internal message in production. Stack traces go to your logging service, not the screen.

Match the fallback's size to the broken region. A failed avatar should fall back to a placeholder image, not a full-page error card. A failed page-level data load can take the whole page. The fallback should feel proportional to what actually broke.

A checklist for vibecoded UIs

AI-generated components are optimized for the happy path, so they're exactly where these gaps hide. When you review AI-written UI code, run through:

  • [ ] Is there at least one top-level error boundary so a render crash can't blank the app?
  • [ ] Are independent data regions wrapped in their own boundaries?
  • [ ] Does every fetch / promise have a .catch or try/catch? (Boundaries won't save you here.)
  • [ ] Does each data-loading component render an explicit error state, not just loading + success?
  • [ ] Do array accesses and .map()s guard against undefined/empty data?
  • [ ] Are user-facing error messages specific and free of raw internals?

Graceful failure isn't a feature you add at the end — it's the difference between an app that degrades and an app that disappears. Build the boundaries early and your worst-case UX becomes "this one part is temporarily unavailable" instead of a blank screen.

Stay in the flow

Get vibecoding tips, new tool announcements, and guides delivered to your inbox.

No spam, unsubscribe anytime.