← back to section

You open a page, and instead of content — a blank white screen. The application has crashed. Most of the time the cause is one small error in one place that dragged everything else down with it. This is preventable.

Let's start from scratch: why this happens, what kinds of errors exist in the frontend, and how to catch them properly.

Why one error takes down the whole application

React renders components as a tree. If an exception is thrown somewhere in that tree — for example, data arrived without an expected field and the code tries to read undefined.name — React doesn't know what to do and by default unmounts the entire tree. The user sees a white screen.

The fix is to tell React explicitly: "if something crashes here, show a fallback screen but leave everything else alone." That's what an error boundary is for.

There are two kinds of errors

Before choosing a tool, it's important to understand the nature of the error:

Render failure — an exception thrown directly in component code: accessing undefined, broken computation logic. These errors are caught by an error boundary.

Data error — a server request returned an error (network dropped, server responded with 500). This is not a render exception — it's an expected state that is handled separately, through component state.

Confusing the two is a common reason "error handling doesn't work": you put up a boundary expecting it to catch a request error — but it won't.

Error boundary: catching render failures

An error boundary is a wrapper component that intercepts exceptions in its subtree and shows a fallback screen instead of crashing. React hasn't added a hook for this yet, so boundaries are written as class components — or you can use the ready-made react-error-boundary library.

Minimal implementation:

class ErrorBoundary extends React.Component<
  { fallback: React.ReactNode; children: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  componentDidCatch(error: unknown) {
    // report to monitoring system here
  }

  render() {
    return this.state.hasError ? this.props.fallback : this.props.children;
  }
}

Usage:

<ErrorBoundary fallback={<ErrorScreen />}>
  <Dashboard />
</ErrorBoundary>

If Dashboard or any component inside it throws an exception, the user sees <ErrorScreen /> instead of a white screen.

Data errors: states instead of boundaries

An HTTP request error won't be caught by a boundary — it's not a render exception. The right approach: the request returns an isError state, and the component handles it explicitly.

If you're using TanStack Query, it looks like this:

function Products() {
  const { data, isPending, isError, refetch } = useProducts();

  if (isPending) return <Spinner />;
  if (isError) return <ErrorBox onRetry={() => refetch()} />;

  return <ProductList products={data} />;
}

The key point: a network error is not a catastrophe — it's an expected state. The network hiccuped — show a message and a "retry" button. The user understands what's happening and can do something about it.

Suspense: declarative loading state

Suspense solves a different problem — showing a loading state. Instead of writing if (isPending) return <Spinner /> in every component, you wrap a subtree and provide a single shared fallback:

<Suspense fallback={<Spinner />}>
  <LazyDashboard />
</Suspense>

While the components inside are "suspended" (loading), Suspense shows the fallback. This works the same way for lazy-loading components with React.lazy.

Suspense and error boundaries are often placed together — they complement each other: one handles "loading," the other handles "crashed."

<ErrorBoundary fallback={<WidgetError />}>
  <Suspense fallback={<Spinner />}>
    <LazyWidget />
  </Suspense>
</ErrorBoundary>

Zone-based isolation: not one boundary for everything

Placing a single error boundary at the application root is not enough. If it fires, the user loses the entire interface.

The better approach is to split the screen into independent zones and wrap each important zone in its own boundary. If one zone crashes, the rest keep working.

<Page>
  <ErrorBoundary fallback={<WidgetError name="Recommendations" />}>
    <Recommendations />
  </ErrorBoundary>

  <ErrorBoundary fallback={<WidgetError name="Recent Orders" />}>
    <RecentOrders />
  </ErrorBoundary>
</Page>

Recommendations crash — orders and navigation stay alive. The user continues working with the product. This is called graceful degradation: the product stays useful even when part of it is broken.

A good fallback isn't just "something went wrong" — it's a clear message with an option to try again.

In short

  • An unhandled exception in a component crashes the entire React tree by default — the user sees a white screen.
  • There are two kinds of errors: render failures (caught by an error boundary) and data errors (handled through state).
  • Error boundary — a wrapper component with getDerivedStateFromError that intercepts exceptions in its subtree and shows a fallback screen.
  • An HTTP request error won't be caught by a boundary — handle it through state (isError) with a retry button.
  • Suspense — a declarative loading boundary; often placed alongside a boundary (one for "loading," the other for "crashed").
  • Boundaries are placed by zone, not one for the entire application — so that a widget crash doesn't take down the whole page.
  • A good fallback includes a clear message and the ability to retry the action.
  • Data fetching: TanStack Query — isPending / isError states and mutations.
  • Performance and optimization — lazy-loading components with React.lazy + Suspense.
  • Components and state — how the React tree is structured and where render failures come from.