Let's be honest — unhandled errors are probably the number one reason apps get one-star reviews. And in React Native, errors can come from all over the place: component rendering, event handlers, async operations, native modules... each one requiring a different approach to catch. Building an app that doesn't just crash and burn means layering multiple defenses on top of each other.
In this guide, I'll walk you through every layer of a production error handling architecture using Expo, Expo Router, and Sentry. All code examples are tested against React Native 0.84, Expo SDK 55, and @sentry/react-native 8.x.
Understanding Error Types in React Native
Before writing any error handling code, it's worth understanding the four distinct categories of errors you'll encounter in React Native — and which tools actually catch each one.
Render Errors
These happen during component rendering, lifecycle methods, or constructors. Think: accessing a property on undefined state, invalid JSX expressions, or those fun infinite render loops. React Error Boundaries are designed specifically for these.
Synchronous JavaScript Errors
Type errors, reference errors, and invalid function calls that occur outside of the render cycle. A global error handler set via ErrorUtils.setGlobalHandler catches these.
Asynchronous Errors
Unhandled promise rejections from API calls, async/await blocks, setTimeout callbacks, and similar patterns. These are sneaky — neither Error Boundaries nor the global handler reliably catches them, so you need a dedicated promise rejection tracker.
Native Crashes
Errors from iOS or Android native modules (segmentation faults, null pointer exceptions in Objective-C or Kotlin, that sort of thing). These bypass the JavaScript runtime entirely and can only be captured by native crash reporters like Sentry or Crashlytics.
| Error Type | Handler | Example |
|---|---|---|
| Render | Error Boundary | Accessing undefined.name in JSX |
| Synchronous JS | ErrorUtils.setGlobalHandler | Calling a non-existent function |
| Async JS | Promise rejection tracker | Unhandled fetch failure |
| Native | Sentry / Crashlytics | Native module segfault |
Building a Custom Error Boundary Component
Error Boundaries have to be class components — there's no getting around it. They rely on getDerivedStateFromError and componentDidCatch lifecycle methods, and React still doesn't have hook-based equivalents for these.
// components/ErrorBoundary.tsx
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
// Log to your crash reporting service
console.error('ErrorBoundary caught:', error, errorInfo.componentStack);
}
private handleRetry = (): void => {
this.setState({ hasError: false, error: null });
};
render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<View style={styles.container}>
<Text style={styles.title}>Something went wrong</Text>
<Text style={styles.message}>
{this.state.error?.message}
</Text>
<TouchableOpacity style={styles.button} onPress={this.handleRetry}>
<Text style={styles.buttonText}>Try Again</Text>
</TouchableOpacity>
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
backgroundColor: '#fef2f2',
},
title: {
fontSize: 20,
fontWeight: '700',
color: '#991b1b',
marginBottom: 8,
},
message: {
fontSize: 14,
color: '#7f1d1d',
textAlign: 'center',
marginBottom: 24,
},
button: {
backgroundColor: '#dc2626',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
buttonText: {
color: '#ffffff',
fontWeight: '600',
fontSize: 16,
},
});
Using the Error Boundary
Wrap your root layout or individual screen sections:
import { ErrorBoundary } from '@/components/ErrorBoundary';
export default function App() {
return (
<ErrorBoundary>
<MainNavigator />
</ErrorBoundary>
);
}
Granular Error Boundaries
Here's a mistake I see a lot: wrapping only the root component. A single rendering error takes down the entire UI. A much better approach is wrapping individual sections, so a crash in one widget doesn't nuke everything else on screen:
function DashboardScreen() {
return (
<View style={{ flex: 1 }}>
<ErrorBoundary fallback={<Text>Chart unavailable</Text>}>
<RevenueChart />
</ErrorBoundary>
<ErrorBoundary fallback={<Text>Feed unavailable</Text>}>
<ActivityFeed />
</ErrorBoundary>
</View>
);
}
Using the react-error-boundary Library
If class components make you cringe (no judgment), the react-error-boundary library by Brian Vaughn — a former React core team member — wraps the class-based Error Boundary logic and exposes it through a much friendlier props and hooks API. It works with all React renderers, including React Native.
npm install react-error-boundary
The library gives you three ways to render a fallback: a static fallback element, a fallbackRender function, or a FallbackComponent:
import { ErrorBoundary } from 'react-error-boundary';
import { View, Text, Button } from 'react-native';
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<View style={{ flex: 1, justifyContent: 'center', padding: 24 }}>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>
Something went wrong
</Text>
<Text style={{ marginVertical: 12 }}>{error.message}</Text>
<Button title="Try Again" onPress={resetErrorBoundary} />
</View>
);
}
export default function App() {
return (
<ErrorBoundary
FallbackComponent={ErrorFallback}
onError={(error, info) => {
// Send to Sentry, Bugsnag, etc.
console.error(error, info.componentStack);
}}
onReset={() => {
// Reset app state if needed
}}
>
<MainNavigator />
</ErrorBoundary>
);
}
Expo Router Built-In Error Boundaries
If you're using Expo Router for file-based routing (and honestly, you probably should be), you get a streamlined error handling API out of the box. Just export an ErrorBoundary function from any route file, and Expo Router automatically wraps that route in a React Error Boundary.
// app/dashboard.tsx
import { View, Text, StyleSheet } from 'react-native';
import { type ErrorBoundaryProps } from 'expo-router';
export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
return (
<View style={styles.errorContainer}>
<Text style={styles.errorTitle}>Dashboard Error</Text>
<Text style={styles.errorMessage}>{error.message}</Text>
<Text style={styles.retryButton} onPress={retry}>
Tap to Retry
</Text>
</View>
);
}
export default function DashboardScreen() {
// Your screen component
return (
<View>
<Text>Dashboard</Text>
</View>
);
}
const styles = StyleSheet.create({
errorContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
errorTitle: {
fontSize: 20,
fontWeight: 'bold',
marginBottom: 8,
},
errorMessage: {
fontSize: 14,
color: '#666',
textAlign: 'center',
marginBottom: 24,
},
retryButton: {
fontSize: 16,
color: '#007AFF',
fontWeight: '600',
},
});
How Error Propagation Works in Expo Router
When a route doesn't export an ErrorBoundary, the error bubbles up to the nearest parent route that does. So you can set a catch-all boundary in your root layout and then override it with more specific UIs in deeper routes:
// app/_layout.tsx — catch-all boundary
export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
return <GenericErrorScreen error={error} retry={retry} />;
}
// app/(tabs)/settings.tsx — screen-specific boundary
export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
return <SettingsErrorScreen error={error} retry={retry} />;
}
Setting Up a Global Error Handler
Error Boundaries only catch render errors. That's it. For uncaught synchronous JavaScript exceptions that happen outside the render cycle — in event handlers, timers, or initialization code — you need a global handler.
React Native exposes ErrorUtils, an internal API that lets you intercept all uncaught JS errors before they crash the app:
// utils/globalErrorHandler.ts
import { Alert, Platform } from 'react-native';
type ErrorHandler = (error: Error, isFatal?: boolean) => void;
let originalHandler: ErrorHandler | null = null;
export function setupGlobalErrorHandler(): void {
// Preserve the original handler so RN's LogBox still works in dev
originalHandler = ErrorUtils.getGlobalHandler();
ErrorUtils.setGlobalHandler((error: Error, isFatal?: boolean) => {
// 1. Log to your crash reporting service
console.error('[GlobalErrorHandler]', { error, isFatal });
// 2. Show user-facing alert for fatal errors
if (isFatal) {
Alert.alert(
'Unexpected Error',
'The app encountered a critical error. Please restart the app.',
[{ text: 'OK' }]
);
}
// 3. Call the original handler (keeps red screen in dev)
if (originalHandler) {
originalHandler(error, isFatal);
}
});
}
Initialize this handler as early as possible — ideally in your app entry point before any component renders:
// app/_layout.tsx
import { useEffect } from 'react';
import { Stack } from 'expo-router';
import { setupGlobalErrorHandler } from '@/utils/globalErrorHandler';
export default function RootLayout() {
useEffect(() => {
setupGlobalErrorHandler();
}, []);
return <Stack />;
}
Tracking Unhandled Promise Rejections
Unhandled promise rejections are, in my experience, the most common source of silent failures in React Native apps. An API call fails without a .catch(), an async function throws without try/catch — and the error just vanishes. No crash, no log, nothing. Unless you explicitly track them.
With Hermes (the default engine since React Native 0.70, upgraded to Hermes V1 in RN 0.84), you can use the HermesInternal API:
// utils/promiseRejectionTracker.ts
export function setupPromiseRejectionTracking(): void {
if (
typeof HermesInternal !== 'undefined' &&
HermesInternal?.enablePromiseRejectionTracker
) {
HermesInternal.enablePromiseRejectionTracker({
allRejections: true,
onUnhandled: (id: number, error: Error) => {
console.warn(
`[UnhandledRejection] ID: ${id}`,
error?.message ?? error
);
// Send to your crash reporting service
},
onHandled: (id: number) => {
// A previously unhandled rejection was later handled
console.info(`[RejectionHandled] ID: ${id}`);
},
});
}
}
Call this alongside your global error handler during app initialization:
// app/_layout.tsx
import { setupGlobalErrorHandler } from '@/utils/globalErrorHandler';
import { setupPromiseRejectionTracking } from '@/utils/promiseRejectionTracker';
export default function RootLayout() {
useEffect(() => {
setupGlobalErrorHandler();
setupPromiseRejectionTracking();
}, []);
return <Stack />;
}
Handling Errors in Event Handlers
This trips people up constantly. Error Boundaries explicitly do not catch errors thrown inside event handlers like onPress, onChangeText, or gesture callbacks. For these, you need good old try/catch blocks.
To avoid scattering try/catch everywhere, create a reusable wrapper:
// utils/safeHandler.ts
type AsyncHandler = (...args: any[]) => Promise<void>;
export function safeHandler(handler: AsyncHandler): AsyncHandler {
return async (...args) => {
try {
await handler(...args);
} catch (error) {
console.error('[SafeHandler] Error in event handler:', error);
// Optionally show a toast or alert
}
};
}
Then use it in your components like this:
import { safeHandler } from '@/utils/safeHandler';
function CheckoutButton() {
const handlePress = safeHandler(async () => {
const result = await processPayment();
router.push('/confirmation');
});
return <Button title="Pay Now" onPress={handlePress} />;
}
Integrating Sentry for Production Crash Reporting
All the local error handlers and boundaries we've built so far protect the user experience — but they can't tell you what's actually happening in production. For that, you need Sentry.
Sentry is by far the most widely used crash reporting platform in the React Native ecosystem, and it covers everything: JavaScript errors, native crashes, performance monitoring, and even session replays.
Step 1: Install Sentry
The easiest path is the Sentry Wizard, which auto-configures everything for you:
npx @sentry/wizard@latest -i reactNative
Or install manually if you prefer more control:
npx expo install @sentry/react-native
Step 2: Configure Metro
Replace the default Expo Metro config with Sentry's wrapper. This assigns unique Debug IDs to bundles and source maps so your stack traces are properly symbolicated:
// metro.config.js
const { getSentryExpoConfig } = require('@sentry/react-native/metro');
const config = getSentryExpoConfig(__dirname);
module.exports = config;
Step 3: Add the Expo Plugin
Add the Sentry config plugin to app.config.js for automatic source map uploads during EAS builds:
// app.config.js
export default {
expo: {
plugins: [
[
'@sentry/react-native/expo',
{
url: 'https://sentry.io/',
project: process.env.SENTRY_PROJECT,
organization: process.env.SENTRY_ORG,
},
],
],
},
};
And store your auth token as an EAS secret — don't ever commit it to source control:
eas secret:create --scope project --name SENTRY_AUTH_TOKEN --value your-token --type string
Step 4: Initialize Sentry in the Root Layout
// app/_layout.tsx
import { Stack } from 'expo-router';
import * as Sentry from '@sentry/react-native';
import { setupGlobalErrorHandler } from '@/utils/globalErrorHandler';
import { setupPromiseRejectionTracking } from '@/utils/promiseRejectionTracker';
import { useEffect } from 'react';
Sentry.init({
dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
tracesSampleRate: 0.2,
profilesSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
integrations: [Sentry.mobileReplayIntegration()],
enableNativeFramesTracking: true,
enableAutoPerformanceTracing: true,
});
function RootLayout() {
useEffect(() => {
setupGlobalErrorHandler();
setupPromiseRejectionTracking();
}, []);
return <Stack />;
}
export default Sentry.wrap(RootLayout);
Step 5: Use Sentry's Error Boundary
Sentry also provides its own ErrorBoundary that automatically reports render errors and can even show a user feedback dialog:
import * as Sentry from '@sentry/react-native';
import { View, Text, Button } from 'react-native';
function FallbackScreen({ resetError }) {
return (
<View style={{ flex: 1, justifyContent: 'center', padding: 24 }}>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>
Something went wrong
</Text>
<Button title="Try Again" onPress={resetError} />
</View>
);
}
export default function App() {
return (
<Sentry.ErrorBoundary
fallback={({ resetError }) => <FallbackScreen resetError={resetError} />}
showDialog
>
<MainNavigator />
</Sentry.ErrorBoundary>
);
}
Putting It All Together: A Production Error Handling Architecture
So, let's see how all these layers fit into a real Expo Router project. This is basically the setup I'd recommend for any production app:
// app/_layout.tsx
import { useEffect } from 'react';
import { Stack } from 'expo-router';
import * as Sentry from '@sentry/react-native';
import { type ErrorBoundaryProps } from 'expo-router';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { setupGlobalErrorHandler } from '@/utils/globalErrorHandler';
import { setupPromiseRejectionTracking } from '@/utils/promiseRejectionTracker';
// Initialize Sentry before component render
Sentry.init({
dsn: process.env.EXPO_PUBLIC_SENTRY_DSN,
tracesSampleRate: 0.2,
profilesSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
replaysSessionSampleRate: 0.1,
integrations: [Sentry.mobileReplayIntegration()],
});
// Root-level catch-all error boundary (Expo Router)
export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
return (
<View style={styles.container}>
<Text style={styles.title}>Oops!</Text>
<Text style={styles.message}>
Something unexpected happened. Our team has been notified.
</Text>
<TouchableOpacity style={styles.button} onPress={retry}>
<Text style={styles.buttonText}>Try Again</Text>
</TouchableOpacity>
</View>
);
}
function RootLayout() {
useEffect(() => {
setupGlobalErrorHandler();
setupPromiseRejectionTracking();
}, []);
return <Stack screenOptions={{ headerShown: false }} />;
}
export default Sentry.wrap(RootLayout);
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 32,
backgroundColor: '#fff',
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: 12,
},
message: {
fontSize: 16,
color: '#666',
textAlign: 'center',
marginBottom: 32,
lineHeight: 24,
},
button: {
backgroundColor: '#007AFF',
paddingHorizontal: 32,
paddingVertical: 14,
borderRadius: 10,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
Architecture Diagram
┌─────────────────────────────────────────────────┐
│ React Native App │
├─────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Layer 1: Expo Router ErrorBoundary │ │
│ │ Catches render errors per route │ │
│ └──────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Layer 2: ErrorUtils.setGlobalHandler │ │
│ │ Catches uncaught sync JS errors │ │
│ └──────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Layer 3: Promise Rejection Tracker │ │
│ │ Catches unhandled async errors │ │
│ └──────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Layer 4: try/catch in Event Handlers │ │
│ │ Catches onPress, onChangeText, etc. │ │
│ └──────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Layer 5: Sentry SDK │ │
│ │ Captures all JS + native crashes, │ │
│ │ source maps, session replays, breadcrumbs│ │
│ └──────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────┘
Testing Your Error Handling
Don't skip this part. I've seen teams ship elaborate error handling code that had never actually been tested — and (surprise) it didn't work when real errors hit. Add temporary error triggers behind a debug menu or use environment checks:
// components/ErrorTestButtons.tsx (development only)
import { View, Button } from 'react-native';
export function ErrorTestButtons() {
if (!__DEV__) return null;
return (
<View style={{ padding: 16, gap: 8 }}>
{/* Test Error Boundary */}
<ThrowOnRender />
{/* Test Global Handler */}
<Button
title="Trigger Sync Error"
onPress={() => {
throw new Error('Test: sync error in event handler');
}}
/>
{/* Test Promise Rejection */}
<Button
title="Trigger Unhandled Rejection"
onPress={() => {
Promise.reject(new Error('Test: unhandled promise rejection'));
}}
/>
{/* Test Sentry */}
<Button
title="Send Test Event to Sentry"
onPress={() => {
Sentry.captureMessage('Test event from dev menu');
}}
/>
</View>
);
}
function ThrowOnRender() {
throw new Error('Test: render error');
}
Best Practices for Production Error Handling
- Layer your defenses. No single mechanism catches every error type. Use Error Boundaries, global handlers, promise trackers, and a crash reporter together. You need all of them.
- Preserve the original handler. Always call
ErrorUtils.getGlobalHandler()before overriding, and forward errors to it. This keeps the red screen and LogBox working in development — trust me, you want those. - Upload source maps. Without symbolicated stack traces, production crash reports are basically unreadable. The Sentry Expo plugin handles this automatically for EAS builds.
- Tune sample rates. Set
tracesSampleRateandprofilesSampleRateto lower values (0.1–0.2) in production to avoid excessive costs. KeepreplaysOnErrorSampleRateat 1.0 so every error gets a session replay. - Add breadcrumbs. Sentry automatically tracks navigation, network requests, and console logs as breadcrumbs. Honestly, the sequence of events leading to a crash is often more useful than the crash itself.
- Show graceful fallbacks. Never show a blank white screen. Every error boundary should render a meaningful fallback with a retry option.
- Use TypeScript. Static typing eliminates entire categories of runtime errors — type errors, undefined property access, invalid function calls — before they ever reach production.
- Test your error paths. Error handling code that's never been tested is error handling code that doesn't work. Build a debug menu with intentional error triggers and actually run through them.
Frequently Asked Questions
Can I use Error Boundaries with functional components?
Not directly — React still doesn't support getDerivedStateFromError or componentDidCatch in hooks. However, the react-error-boundary library gives you a pre-built class component that you consume with a functional API (props and render callbacks). And if you're on Expo Router, you can just export an ErrorBoundary function from any route file — the framework handles the class component wrapper for you internally.
Why doesn't my Error Boundary catch errors in onPress handlers?
Because React Error Boundaries only catch errors during rendering, lifecycle methods, and constructors. Event handlers like onPress and onChangeText run outside the render cycle, so boundaries don't see them. Use try/catch blocks inside event handlers or create a reusable safeHandler wrapper (shown earlier in this guide).
Do I still need react-native-exception-handler with Sentry?
In most cases, no. Sentry's SDK (@sentry/react-native 8.x) already intercepts uncaught JS exceptions, unhandled promise rejections, and native crashes. The react-native-exception-handler package is really only useful if you're not using Sentry and want basic global exception handling without a full crash reporting platform.
How do I handle errors in Expo Router data loaders?
When a loader throws, the error propagates to the nearest route that exports an ErrorBoundary. You can export one from the same route file as the loader to handle errors locally, or let them bubble up to a parent layout's boundary for a catch-all experience.
What's the difference between sentry-expo and @sentry/react-native?
The sentry-expo package is deprecated — don't use it for new projects. All Expo projects should use @sentry/react-native (version 8.0+), which has first-class Expo support. That includes the @sentry/react-native/expo config plugin, automatic source map uploads with EAS Build, and the getSentryExpoConfig Metro helper.