Error Boundaries and Crash Reporting

Catch a render error so one bad screen does not blank the app, and get real stack traces out of production.

Syntaxstatic getDerivedStateFromError(error: Error): { error: Error | null }

An error thrown while React renders unmounts the whole tree. In development LogBox shows it on a red screen; in release the user gets a blank app. An error boundary is a component that catches errors thrown while rendering its subtree and shows a fallback instead.

Writing one

A boundary must be a class component. static getDerivedStateFromError(error) returns the new state that switches to the fallback, and componentDidCatch(error, info) is where you report it; info.componentStack names the components that were rendering. React 19 has no hook equivalent, so this is the one place in this course where a class is required. Place a boundary per screen, not only at the root, so the navigation shell survives. Expo Router accepts an exported ErrorBoundary from a route file and hands it error and retry props.

What boundaries do not catch

Errors thrown in an onPress handler, inside a promise, or in native code never pass through render. Wrap those in try / catch and report them yourself. A crash reporter installs a global handler for uncaught errors and unhandled rejections, plus native handlers, which is why it sees crashes your boundary cannot.

Sentry and source maps

Install with npx expo install @sentry/react-native, add the @sentry/react-native/expo config plugin, use getSentryExpoConfig in metro.config.js, call Sentry.init({ dsn }) and export Sentry.wrap(RootLayout). EAS Build uploads source maps when SENTRY_AUTH_TOKEN is set in the build environment. After eas update, run npx @sentry/expo-upload-sourcemaps dist, or your over-the-air builds report minified frames.

Example

Example · tsx
import { Component, type ErrorInfo, type ReactNode } from "react";
import * as Sentry from "@sentry/react-native";
import { Pressable, StyleSheet, Text, View } from "react-native";

type ErrorBoundaryProps = { children: ReactNode };
type ErrorBoundaryState = { error: Error | null };

// A class, deliberately: getDerivedStateFromError and componentDidCatch have
// no hook equivalent in React 19.
export default class ErrorBoundary extends Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  state: ErrorBoundaryState = { error: null };

  // Runs during render: return state only, no side effects here.
  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return { error };
  }

  // Runs after the commit: the right place for reporting.
  componentDidCatch(error: Error, info: ErrorInfo) {
    Sentry.captureException(error, {
      extra: { componentStack: info.componentStack },
    });
  }

  render() {
    const { error } = this.state;

    if (error) {
      return (
        <View style={styles.container}>
          <Text style={styles.title}>This screen could not load</Text>
          <Text style={styles.message}>{error.message}</Text>
          {/* A boundary never resets itself; the retry has to clear state. */}
          <Pressable onPress={() => this.setState({ error: null })} style={styles.button}>
            <Text style={styles.buttonText}>Try again</Text>
          </Pressable>
        </View>
      );
    }

    return this.props.children;
  }
}

const styles = StyleSheet.create({
  container: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, gap: 10 },
  title: { fontSize: 18, fontWeight: "600" },
  message: { color: "#6b7280", textAlign: "center" },
  button: { backgroundColor: "#111827", paddingHorizontal: 16, paddingVertical: 10, borderRadius: 8 },
  buttonText: { color: "#ffffff" },
});

When to use it

  • One broken detail screen currently blanks the whole app, including the tab bar.
  • Production stack traces arrive as minified offsets and name no files.
  • An upload fails for a handful of users and nobody can reproduce it.
  • An over-the-air update introduced a crash and you need to know which release it started in.

More examples

Wiring Sentry into an Expo app

The four pieces have to agree: the config plugin builds the native SDK in, the Metro config makes source maps Sentry can read, init supplies the DSN, and wrap installs the handlers. SENTRY_AUTH_TOKEN belongs in the EAS build environment with sensitive visibility, never in the repository. Without the token the build still succeeds and every trace arrives minified.

Example · tsx
// app.json
// {
//   "expo": {
//     "plugins": [
//       ["@sentry/react-native/expo", {
//         "url": "https://sentry.io/",
//         "project": "mobile",
//         "organization": "acme"
//       }]
//     ]
//   }
// }

// metro.config.js
// const { getSentryExpoConfig } = require("@sentry/react-native/metro");
// module.exports = getSentryExpoConfig(__dirname);

// app/_layout.tsx
import * as Sentry from "@sentry/react-native";
import { Stack } from "expo-router";

Sentry.init({
  dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
  // Off by default; turning it on sends IP addresses and similar data.
  sendDefaultPii: false,
  // Fraction of transactions traced. 1.0 is fine while you are setting up.
  tracesSampleRate: 0.2,
});

function RootLayout() {
  return <Stack />;
}

// wrap() adds the error handlers and performance instrumentation.
export default Sentry.wrap(RootLayout);

A per-route boundary in Expo Router

This is the cheapest way to stop one broken screen from blanking the app: the tab bar and the rest of the navigation state stay alive, and the user can retry or go elsewhere. retry re-mounts the route, so it is genuinely useful when the failure was a bad response rather than a bad component.

Example · tsx
import type { ErrorBoundaryProps } from "expo-router";
import { Pressable, StyleSheet, Text, View } from "react-native";
import ReportBody from "../components/ReportBody";

// A named export called ErrorBoundary in a route file catches errors from
// that route only. Expo Router passes error and retry; this one can be a
// function component because the router owns the class.
export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
  return (
    <View style={styles.container}>
      <Text style={styles.title}>Report unavailable</Text>
      <Text style={styles.message}>{error.message}</Text>
      <Pressable onPress={retry} style={styles.button}>
        <Text style={styles.buttonText}>Retry</Text>
      </Pressable>
    </View>
  );
}

export default function ReportScreen() {
  return <ReportBody />;
}

const styles = StyleSheet.create({
  container: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24, gap: 10 },
  title: { fontSize: 18, fontWeight: "600" },
  message: { color: "#6b7280", textAlign: "center" },
  button: { backgroundColor: "#111827", paddingHorizontal: 16, paddingVertical: 10, borderRadius: 8 },
  buttonText: { color: "#ffffff" },
});

Reporting an error a boundary will never see

A button that shows Uploading while the request runs and an error line if it fails. This is the pattern for every async failure: handle it for the user with state, and report it for yourself with context attached. Tags are the fields you filter the dashboard by later, so keep them to a small fixed set of values.

Example · tsx
import { useState } from "react";
import * as Sentry from "@sentry/react-native";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { uploadReceipt } from "../lib/api";

type UploadButtonProps = { receiptUri: string };

export default function UploadButton({ receiptUri }: UploadButtonProps) {
  const [status, setStatus] = useState<"idle" | "busy" | "failed">("idle");

  async function onPress() {
    setStatus("busy");
    try {
      await uploadReceipt(receiptUri);
      setStatus("idle");
    } catch (error) {
      // Thrown in a promise, not in render, so no boundary catches it.
      setStatus("failed");
      Sentry.captureException(error, {
        // Tags are searchable in the dashboard; extra is only readable.
        tags: { feature: "receipt-upload" },
        extra: { receiptUri },
      });
    }
  }

  return (
    <View style={styles.container}>
      <Pressable onPress={onPress} disabled={status === "busy"} style={styles.button}>
        <Text style={styles.buttonText}>
          {status === "busy" ? "Uploading" : "Upload receipt"}
        </Text>
      </Pressable>
      {status === "failed" ? <Text style={styles.error}>Upload failed. Try again.</Text> : null}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { padding: 16, gap: 8 },
  button: { backgroundColor: "#1f6feb", padding: 12, borderRadius: 8, alignItems: "center" },
  buttonText: { color: "#ffffff" },
  error: { color: "#b91c1c" },
});

Discussion

  • Be the first to comment on this lesson.