Error Tracking for React Native Apps: Source Maps, Crash Reports, and JS Errors in One Place
Set up unified error tracking for React Native with source maps, crash reports, and screen-level analytics — no separate SDK required.
Say a crash report lands at 4:17 PM on a Friday: "TypeError: undefined is not an object (evaluating 'user.profile.avatar')" with a stack trace pointing to index.android.bundle:1:847293. Hermes bytecode. Minified. No source map uploaded. Now you're binary-searching through git commits trying to figure out which change broke the profile screen.
One line.
The fix is one line. A missing optional chaining operator. user?.profile?.avatar instead of user.profile.avatar. Finding it can take longer than an entire weekend, and it's easy to walk into this basic React Native debugging trap.
This tutorial walks through wiring error tracking into a React Native app so you don't end up in that Friday. We're using JustAnalytics because it bundles error tracking with screen analytics in one SDK — no separate analytics tool, no conflicting session IDs, no "which SDK caught this crash?" confusion. The patterns work for Sentry or Crashlytics with minor changes if you're already committed to those (though I think the consolidation angle makes more sense for most teams — but that's me).
What we're building
By the end of this, you'll have:
- JavaScript error tracking that catches runtime exceptions, unhandled promise rejections, and component crashes
- Crash reporting through a global JavaScript error handler plus an
ErrorBoundary - Source map uploads that work with Metro, Hermes bytecode, and Expo EAS Build
- Screen tracking through the navigation listener, in the same SDK as your errors
The whole setup is about 80 lines of code. Less if you've done this dance before.
Prerequisites
- React Native 0.72+ (we're using 0.74 in examples — older versions work but hook signatures differ)
- Node.js 18+
- A JustAnalytics account (free tier: 100K events/month, zero dollars)
- React Navigation 6+ if you want screen tracking
- Xcode 15+ for iOS builds, Android Studio for Android
Expo works for both managed workflow and bare/EAS Build. Managed workflow has one extra step for source maps that I'll call out.
Step 1: Install the SDK
npm install @justanalyticsapp/react-native
For iOS, run pod install:
cd ios && pod install && cd ..
Android auto-links. No extra steps.
Now initialize in your app entry point. If you're using index.js:
// index.js
import { AppRegistry } from 'react-native';
import JustAnalytics from '@justanalyticsapp/react-native';
import App from './App';
JustAnalytics.init({
siteId: 'your-site-id',
apiKey: 'ja_sk_your_api_key',
environment: 'production',
release: '1.0.0',
});
AppRegistry.registerComponent('YourApp', () => App);
The order matters. Initialize before registerComponent so errors during app startup get captured. Initializing inside App.tsx can miss the entire first render cycle of crashes, and the crash rate then looks suspiciously low.
Quick test: throw a deliberate error in a component, run the app, check your dashboard. Shows up within a few seconds? You're wired correctly. Doesn't? Check your site ID. Copy-paste errors are common.
Step 2: Add the error boundary
React Native's default error handling shows a red screen in development and crashes silently in production. Neither is useful. We need an error boundary.
// ErrorBoundary.tsx
import React, { Component, ReactNode } from 'react';
import { View, Text, Button, StyleSheet } from 'react-native';
import JustAnalytics from '@justanalyticsapp/react-native';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
JustAnalytics.captureException(error);
}
handleRetry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return this.props.fallback || (
<View style={styles.container}>
<Text style={styles.title}>Something went wrong</Text>
<Text style={styles.message}>{this.state.error?.message}</Text>
<Button title="Try again" onPress={this.handleRetry} />
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
title: { fontSize: 18, fontWeight: 'bold', marginBottom: 10 },
message: { fontSize: 14, color: '#666', textAlign: 'center', marginBottom: 20 },
});
Wrap your app:
// App.tsx
import { ErrorBoundary } from './ErrorBoundary';
export default function App() {
return (
<ErrorBoundary>
<NavigationContainer>
{/* your screens */}
</NavigationContainer>
</ErrorBoundary>
);
}
The componentStack from React's errorInfo tells you which component tree led to the crash — ProfileScreen > UserAvatar > Image — not just the file and line number. This alone saves hours. Without it, you get "something crashed" with no context about where the user was in the app. Useless.
Step 3: Crash reporting
The SDK reports crashes through a global JavaScript error handler, plus the ErrorBoundary you added in Step 2. The SDK README documents JavaScript-level crash reporting only, so this guide doesn't cover native crash capture.
Step 4: Upload source maps
Here's where most teams break down. React Native bundles your JS into index.android.bundle and index.ios.bundle, minified and unreadable. Hermes compiles that to bytecode. Source maps connect the minified output back to your original code.
Add the upload script to your build process. For a standard Metro setup:
# Build for Android with source maps
npx react-native bundle \
--platform android \
--dev false \
--entry-file index.js \
--bundle-output android/app/src/main/assets/index.android.bundle \
--sourcemap-output android/app/src/main/assets/index.android.bundle.map
# Upload (set JUSTANALYTICS_API_KEY in the environment)
npx @justanalyticsapp/cli sourcemaps upload \
--release $(node -p "require('./package.json').version") \
--site-id YOUR_SITE_ID \
android/app/src/main/assets/index.android.bundle.map
For iOS, same pattern with the iOS source map path. You can also register the release with npx @justanalyticsapp/cli releases new --version <your release>.
If you're using Hermes (and you should be — it's faster), Metro generates a Hermes source map. Upload the source map with the CLI in your build step, as shown above.
For Expo EAS Build, add a post-build hook in eas.json:
{
"build": {
"production": {
"android": {
"buildType": "apk"
},
"ios": {
"buildConfiguration": "Release"
}
}
},
"submit": {},
"cli": {
"postBuildHook": "node ./scripts/upload-sourcemaps.js"
}
}
Where upload-sourcemaps.js calls @justanalyticsapp/cli sourcemaps upload with the artifacts EAS produces. The paths are in $EAS_BUILD_ARTIFACTS_PATH.
The Expo EAS artifact paths are easy to get wrong. Check Expo's docs for the current directory structure, since it can change between SDK versions.
Step 5: Wire up screen tracking
Errors without context are useless. "Crash happened" — okay, where? Which screen? What was the user doing?
If you're using React Navigation 6+:
// App.tsx
import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native';
import JustAnalytics from '@justanalyticsapp/react-native';
export default function App() {
const navigationRef = useNavigationContainerRef();
return (
<ErrorBoundary>
<NavigationContainer
ref={navigationRef}
onReady={() => JustAnalytics.recordInitialRoute(navigationRef)}
onStateChange={JustAnalytics.createNavigationListener(navigationRef)}
>
{/* your screens */}
</NavigationContainer>
</ErrorBoundary>
);
}
Now screen views are recorded through the navigation listener, in the same SDK as your errors.
This is the consolidation angle that Sentry misses. Sentry gives you crashes. Great. But if you want "users who saw this error vs. users who didn't — what's the conversion difference?" — you need a separate analytics tool. And then you're correlating session IDs across two systems, which is a special kind of hell. With JustAnalytics, errors and screens share the same session. I genuinely think this is how mobile observability should work, but I'm biased.
Common errors and how to fix them
"JustAnalytics.init is not a function"
The import is wrong, or the native module didn't link. Run npx react-native link @justanalyticsapp/react-native (for older RN versions) or check that pod install ran without errors. On Android, sometimes a clean build fixes it: cd android && ./gradlew clean && cd ..
Stack traces show minified code despite uploading source maps
Version mismatch. The bundle version in your app doesn't match the source map you uploaded. Make sure the --release flag in your upload command matches versionName in your build. This is easy to miss when a hotfix skips the upload step.
"Cannot read property 'captureException' of undefined"
You're calling JustAnalytics.captureException before init() has run. Move initialization earlier in your entry point.
Screen tracking fires twice per navigation
React Navigation's onStateChange fires for nested navigators. Add a guard comparing previousRouteName !== currentRouteName before tracking. Already in the code above, but easy to miss if you're adapting.
Next steps
Error tracking and screen analytics are wired. Some things worth adding:
User identification. Tie errors to specific users so you can reach out when something breaks. Call JustAnalytics.identify({ userId: 'user_123', email: 'user@example.com' }) after login. Don't include PII you don't need — user ID and email are usually enough.
Release health dashboards. Tag errors with your app version and build number. The release health and regression alerts guide covers alerts when a new release increases crash rates.
Network and performance. The SDK intercepts fetch and XHR calls with distributed tracing, and measures app start time and screen render time, which helps when a crash follows a slow request. Teams running ad campaigns sometimes pair error data with ClickzProtect to spot crashes correlating with suspicious traffic sources.
Distributed tracing. If your app talks to backend APIs, connect the traces. Pass the trace ID header from fetch calls and you'll see the full request lifecycle — client → API → database → response — with errors pinpointed to the exact hop that failed. The APM and P99 latency debugging post covers this.
Frequently Asked Questions
Can JustAnalytics replace Sentry for React Native?
It covers the core error tracking surface — JS exceptions, source maps, issue grouping — plus screen analytics, network tracking and performance monitoring in one SDK. If you're paying for Sentry and a separate mobile analytics tool, consolidating makes sense. Sentry has deeper native symbolication for complex C++ crashes and more mature release health dashboards, so evaluate based on your crash complexity.
Does this work with Expo?
Yes. For managed Expo projects, install the SDK and configure source map uploads through expo-updates. For bare workflow and EAS Build, the setup is identical to standard React Native. The only difference is where your build artifacts live — EAS stores them differently than a local Metro build.
How do source maps work with Hermes bytecode?
Hermes compiles JS to bytecode, which requires an extra deobfuscation step. Upload the source map that Metro generates with @justanalyticsapp/cli sourcemaps upload in your build step, so stack traces can be mapped back to your original file names and line numbers.
What about screen analytics — do I need a separate tool?
No. Screen views are recorded through the navigation listener you set up (recordInitialRoute and createNavigationListener for React Navigation, trackExpoRouterNavigation for Expo Router). You get screen funnels, retention by screen, and error correlation to specific screens without a second analytics SDK. That's the consolidation angle — errors and analytics share the same session ID.
Try JustAnalytics
All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier: 100K events/mo, $0. Pro: $49/month ($39 billed annually).
Author at JustAnalytics.