React Native – Error Boundaries and Failure States

August 12, 20264 min readUpdated 8/24/2026

On the web, a crashed page has a reload button. On a phone it is a blank screen the user cannot refresh away — they force-quit the app and, often, do not come back. Failure states matter more here than they do in a browser.

Error boundaries, and what they do not catch

An error boundary catches an error thrown while rendering a subtree and shows a fallback instead of unmounting everything.

Expo Router looks for an exported ErrorBoundary in a layout file and wraps that layout's screens in it:

export function ErrorBoundary({ error, retry }: { error: Error; retry: () => Promise<void> }) {

Exporting it from the root layout means any screen that throws shows this instead of a red box in development or a blank white app in production. It is a plain function rather than a class, because the router supplies the caught error and a retry function as props — the componentDidCatch machinery lives in the router.

⚠️ What an error boundary does not catch, and this is most of the code that fails: errors thrown in an event handler, in a timeout, in an async function, or during server rendering. None of those are part of rendering.

So a failed API call inside onPress sails straight past. Boundaries are the last resort for "something we did not anticipate", not the error handling strategy — which is why every API call in the demo app has its own try/catch and its own error state.

Give the fallback something useful

A fallback that says "Something went wrong" and nothing else is only marginally better than the blank screen. Show the message, and offer the retry the router already handed you. In production, report it — Sentry or Bugsnag both have React Native SDKs and both capture native crashes as well as JavaScript ones, which matters because a native crash never reaches a boundary at all.

Typed errors

Distinguishing failures is what lets the UI say something true:

export class ApiError extends Error {
  readonly status: number;
  readonly body: ApiErrorBody | null;

One type for "the server answered and said no", carrying the status and the parsed body, and another for "the request never arrived". They lead to different messages — and on a phone the second usually means a lost signal rather than a backend that is down, which is a much more useful thing to tell someone.

    Object.setPrototypeOf(this, ApiError.prototype);
  }

⚠️ That line is not ceremony. Subclassing a built-in like Error breaks instanceof when the code is transpiled down to ES5, which Metro still does for some targets. Every branch that distinguishes an API failure from a network failure depends on that check, so it silently stops working without it.

One place that decides what the user reads

export function toUserMessage(error: unknown, fallback = 'Something went wrong.'): string {
  if (error instanceof ApiError) return error.message;
  if (error instanceof NetworkError) return error.message;
  if (error instanceof Error) return error.message || fallback;
  return fallback;
}

Note the last line. catch gives you unknown, and JavaScript lets you throw anything — a string, an object, undefined. Assuming an Error is how you get "cannot read property message of undefined" inside your error handler, which is a particularly bleak failure.

Centralising this means no screen repeats the instanceof ladder, and the wording changes in one place.

Cancellation is not failure

Worth restating because it produces such confusing bugs. When a screen unmounts mid-request the abort is the correct outcome, not an error. Check the signal before setting an error state, or navigating away paints an error on the way out — and the user sees a red message about a screen they already left.

The same applies to a dismissed payment sheet, which Stripe reports as an error with code Canceled. Lesson 19 covers it.

Three states, and the one that gets forgotten

Every data screen owes the user loading, empty and error. The error branch is the one that gets skipped, and the result is a spinner that spins forever when the backend is down — which reads as a frozen app.

Building them as components rather than reimplementing the pattern per screen is the cheapest way to stop forgetting one. The demo app keeps LoadingState, EmptyState and ErrorState together in one file precisely because they are one decision, not three.

An empty state is worth as much care as an error. A list that renders as blank space reads as broken; a sentence explaining why, and a button that does something about it, reads as working.

Failures that should not surface at all

Not every error deserves a message. The demo app's cart save fails silently: the cart still works in this session, it just will not survive a relaunch, and a toast on every tap would be worse than the failure. Storage reads fail silently for the same reason.

The test is whether the user can do anything about it. If not, log it and carry on.

Development-only tools

LogBox shows warnings and errors in-app. Resist the urge to silence a warning you do not understand — the "each child in a list should have a unique key" one in particular is telling you about a real recycling bug.

The red screen is development only. In production a JavaScript error that is not caught takes the app to a blank screen instead, which is exactly why the root boundary matters.

Shake the device — or press d in the terminal — for the dev menu, which is where the performance monitor and the element inspector live.

What is next

Testing — what to test, and the current version's one sharp edge.