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.
static 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
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.
// 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.
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.
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