Skip to content
OTFotf
All posts

One crashing screen should not kill the app: error boundaries that hold

D
DaveAuthor
9 min read
One crashing screen should not kill the app: error boundaries that hold

AI coding assistants write screens fast and break them faster. One undefined field from a changed API, one date parse on a null value, one image with a missing URI, and the whole app goes white. The user did nothing wrong. The crash was a rendering mistake in a corner they never touched.

React Native has exactly one built-in answer for render failures: the error boundary. It is a class component that catches JavaScript errors during rendering, logs them, and shows fallback UI instead of unmounting everything. Most AI-generated codebases ship without a single one. The default export is the screen, the error propagates to the root, and the app blanks.

This piece covers where boundaries belong in a React Native app, what fallback UI should do, how to log catches with release context, what boundaries cannot catch, and how to prove the setup before users find the holes.

Boundaries are the only render safety net

An error boundary must be a class component. There is no hook equivalent. It implements a static method that updates state when a child throws, plus a lifecycle method that fires on the catch for logging. That is the entire mechanism, and it only works for errors thrown during rendering, in lifecycle methods, and in constructors of the tree below it.

The minimal shape is small enough to keep in one file. The snippet below is illustrative, adapt prop names to your design system:

import { Component, type ReactNode } from 'react';
import { View, Text, Button } from 'react-native';

type Props = { children: ReactNode; onCatch?: (error: Error) => void };
type State = { hasError: boolean };

export class ScreenBoundary extends Component<Props, State> {
  state: State = { hasError: false };

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

  componentDidCatch(error: Error) {
    this.props.onCatch?.(error);
  }

  private handleRetry = () => {
    this.setState({ hasError: false });
  };

  render() {
    if (this.state.hasError) {
      return (
        <View>
          <Text>Something on this screen broke.</Text>
          <Button title="Try again" onPress={this.handleRetry} />
        </View>
      );
    }
    return this.props.children;
  }
}

Two details matter. First, the reset. A boundary that stays broken after one failure forces the user to kill the app. Provide a retry that clears the error state, ideally keyed to navigation so leaving and returning also resets. Second, the logging hook. The boundary is the only place that sees the original error with the component stack, so pass every catch to your reporter with the current route name attached.

The canonical description of the class contract lives in the React Component reference linked in Sources, verified live today. That page is the ground truth for which methods exist and why function components cannot serve as boundaries. Everything below builds on that contract without repeating API details that drift between releases.

Place them where failure hurts least

One top-level boundary is barely better than none. It converts a white screen into a full-app error card, which still destroys the session. The goal is isolation: a broken feed card should not kill the profile tab, and a broken profile tab should not kill navigation.

A layout that holds up in production uses three levels. Wrap each tab or stack screen in its own boundary so a crash in one screen leaves the tab bar alive. Wrap risky widgets inside screens, such as rich media cards, charts, and third-party embeds, so one bad item degrades instead of blanking the list. Keep one root boundary as the last resort with a restart affordance, and treat every hit on it as a severity-one bug because full-app fallback means the per-screen layer failed.

Name boundaries by route in your logs. A catch tagged settings-screen tells you where to look. An untagged catch sends you hunting through the whole tree. Pass the route name as a prop at each usage site rather than trying to infer it inside the boundary, because inference breaks the moment navigators nest.

Resist the temptation to wrap everything individually. Boundaries add class components to a hooks codebase, and a hundred of them obscure the tree. Screen level plus risky-widget level covers nearly every real crash. Add more only where crash reports cluster.

One codebase. iOS, Android, and web.

The Fitness Kit ships with auth, a database, and a backend already connected — no setup. Live demo at fitness-preview.otf-kit.dev.

See the live demo

Fallbacks that help instead of shaming

Fallback UI is a support interaction, not an apology page. The user lost content through no fault of their own. The fallback should say what happened in plain words, preserve navigation, and offer exactly two actions: try again and go back safely.

Try again re-renders the children from a clean state. Go back pops to the previous screen or the tab home so nobody gets trapped on a dead route. Include a compact report line with a short incident identifier when your reporter returns one, so support can match the screenshot to the logged event. Skip stack traces, skip jargon, skip blame. Nobody has ever been helped by the phrase unhandled exception.

Design the fallback once in your design system and reuse it everywhere. Different copy per screen feels personal but drifts into inconsistent actions, and inconsistent actions generate support tickets. One component, one wording family, route name injected. Dark mode, large text, and screen-reader labels all apply here because crashes do not check accessibility settings first.

For teams that already gate routes on session state, keep the boundary outside the auth check, not inside. A crash inside the guard should show the fallback, never a signed-out screen, because signing the user out on a render bug destroys trust and data entry. The guard pattern in Expo Router auth guards that survive expired sessions stays the source of navigation truth, and the boundary stays the source of render-failure truth. Neither should impersonate the other.

Log every catch with a release tag

An unlogged boundary is a rug with a trapdoor. The app looks stable while screens silently fail, and you learn about it from reviews instead of dashboards. Every catch needs three fields: the error and component stack, the route name, and the release identifier.

Wire the logging hook to your crash reporter in one helper so all boundaries behave identically. The snippet below is illustrative, match the call to your reporter SDK:

async function reportBoundaryCatch(params: {
  error: Error;
  route: string;
  release: string;
}): Promise<string | null> {
  // Send error, component stack, route, and release to your reporter.
  // Return the incident id for display in fallback UI when available.
  return null;
}

Release tags separate new breakage from old. When a fresh bundle lands through over-the-air update or a store build, a spike in boundary catches on the new release points straight at the diff. Without the tag, you argue about whether the crash is new while users churn. The release-health workflow in Sentry error tracking for React Native in production shows how to read that signal and tie it to a deploy decision.

Sample deliberately. High-traffic screens can generate floods from a single bad item repeated in a list. Rate-limit identical catches per session, attach the count, and keep the first full stack. Your quota survives, and the pattern stays visible instead of drowning in duplicates.

Review boundary catches weekly alongside fatal crashes. Fallbacks hide pain from users but not from retention. A screen that fails for five percent of visits without a single fatal crash still costs you the cohort that never comes back.

What boundaries never catch

Boundaries have sharp edges, and AI-generated code loves to fall off them. They do not catch errors in event handlers, asynchronous callbacks, or native module failures. A submit button that throws inside its press handler bypasses the boundary entirely. A rejected promise from a fetch call never reaches it. A native crash in video playback or maps takes down the process regardless of how many boundaries you placed.

Cover each gap with its own tool. Wrap event-handler bodies in try and catch blocks that route to the same reporter and show inline form errors instead of global fallbacks. Await network calls inside explicit error handling that distinguishes offline, timeout, and server errors, because each needs different copy. Treat native crashes as a separate pipeline with native symbolication and device metadata, not as boundary misses.

State corruption deserves special attention. A boundary retry that re-renders with the same poisoned cache or the same malformed item crashes again instantly, which reads as a broken retry button. Where feasible, the retry action should clear the suspect input: drop the cached page, reset the form draft to the last saved snapshot, or skip the offending list item and report its identifier. Log which recovery ran so you can tell a code bug from a data bug in the dashboard.

Type discipline prevents more boundary hits than any placement strategy. The crashes that reach boundaries are disproportionately null traversals, date parses on missing values, and shape mismatches after an API change. Generated code that trusts the network without validation will keep your fallbacks busy. Validate at the edge, default defensively, and let the boundary handle the residue instead of the flood.

Prove it before users do

Boundaries are easy to claim and easy to leave untested. Three checks make the claim real.

First, throw on purpose in development. Add a temporary screen that throws during render, nested inside your standard boundary, and confirm the fallback appears with working retry and back actions. Remove the throw before merging. If your team uses preview builds for agent-written code, run this check there so reviewers see the fallback instead of trusting the diff. The preview workflow in Preview deployments keep AI coding agents honest before merge is the right place to attach that screenshot.

Second, ship the fallback copy through the same review as the feature. Designers should see the broken state of every new screen, not just the happy state. A five-minute review of fallback wording per pull request prevents the shipped embarrassment of placeholder text on the most visible error in the app.

Third, connect boundary spikes to your rollback plan. A release that doubles catches on one route within the hour is a rollback candidate even with zero fatal crashes. Decide the threshold before the release, not during the incident. The rollback mechanics in EAS Update rollback plan keeps one bad bundle from stranding your users give you the lever, and the boundary dashboard tells you when to pull it. If you maintain starter screens from the /templates gallery, confirm the boundary wrapper ships in the template so new screens inherit protection instead of opting in.

Error boundaries done right disappear into the background. Screens fail small, logs carry the route and release, retries actually recover, and the gaps outside render errors get their own handling. Nobody praises the fallback they never needed. They simply stay, which is the entire point.

Sources

react-nativearchitecturecross-platform
OTF Fitness Kit

Stop wiring. Start shipping.

  • Login, database, and backend already connected — nothing to set up
  • iOS + Android + web from one codebase
  • AI configs pre-tuned + 40+ tested prompts included