Error Tracking for React Native Apps: Source Maps, Native Crashes, and JS Errors in One Place
EngineeringAugust 24, 202611 min read

Error Tracking for React Native Apps: Source Maps, Native Crashes, and JS Errors in One Place

Set up unified error tracking for React Native with source maps, native crash reports, and screen-level analytics — no separate SDK required.

The crash report landed 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. I spent three hours binary-searching through git commits trying to figure out which change broke the profile screen.

One line.

The fix was one line. A missing optional chaining operator. user?.profile?.avatar instead of user.profile.avatar. Finding it took longer than my entire weekend, which — honestly — felt like a personal failing. I've been doing this for years and I still walked into the most basic React Native debugging trap.

This tutorial walks through wiring error tracking into a React Native app so you don't repeat my 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
  • Native crash reporting for iOS (NSException, Mach exceptions) and Android (Java/Kotlin exceptions, NDK crashes)
  • Source map uploads that work with Metro, Hermes bytecode, and Expo EAS Build
  • Automatic screen tracking tied to the same session 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 automatic 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. (I wish Expo made this clearer in their docs — they bury the artifact paths.)

Step 1: Install the SDK

npm install @justanalytics/react-native@^3.1.0

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 '@justanalytics/react-native';
import App from './App';

JustAnalytics.init({
  siteId: 'your-site-id',
  trackErrors: true,
  trackScreens: true,
  enableNativeCrashReporting: true,
});

AppRegistry.registerComponent('YourApp', () => App);

The order matters. Initialize before registerComponent so errors during app startup get captured. I've seen teams initialize inside App.tsx and miss the entire first render cycle of crashes — and then wonder why their crash rate 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 embarrassingly common. I've done it twice this year.

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 '@justanalytics/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.captureError(error, {
      componentStack: errorInfo.componentStack,
      context: 'react_error_boundary',
    });
  }

  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: Capture native crashes

JavaScript errors are one thing. Native crashes — a C++ assertion failure in a native module, a Java exception in a third-party SDK, an iOS memory termination — those need native integration.

For iOS, the SDK uses PLCrashReporter under the hood. For Android, it hooks into the uncaught exception handler and NDK signal handlers.

Most of this is automatic, but you need to ensure symbolication works. Add to your Podfile:

# ios/Podfile
post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['DEBUG_INFORMATION_FORMAT'] = 'dwarf-with-dsym'
    end
  end
end

For Android, ensure ProGuard/R8 mapping files get uploaded. In android/app/build.gradle:

android {
  buildTypes {
    release {
      minifyEnabled true
      proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
    }
  }
}

// Upload mapping file after release build
task uploadProguardMapping {
  doLast {
    exec {
      commandLine 'npx', 'ja-upload-mapping',
        '--site-id', System.getenv('JA_SITE_ID'),
        '--api-key', System.getenv('JA_API_KEY'),
        '--mapping', "${buildDir}/outputs/mapping/release/mapping.txt",
        '--version', android.defaultConfig.versionName
    }
  }
}
afterEvaluate {
  tasks.named('assembleRelease').configure {
    finalizedBy 'uploadProguardMapping'
  }
}

Native crash reports without symbols are useless — you get memory addresses instead of function names. The upload step adds maybe 15 seconds to your release build. Annoying? Sure. Worth it? Absolutely.

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
npx ja-upload-sourcemap \
  --site-id $JA_SITE_ID \
  --api-key $JA_API_KEY \
  --sourcemap android/app/src/main/assets/index.android.bundle.map \
  --bundle android/app/src/main/assets/index.android.bundle \
  --platform android \
  --version $(node -p "require('./package.json').version")

For iOS, same pattern with --platform ios and the iOS bundle path.

If you're using Hermes (and you should be — it's faster), Metro generates a Hermes source map. The SDK handles the two-stage deobfuscation: bytecode → JS → original source.

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 the upload CLI with the artifacts EAS produces. The paths are in $EAS_BUILD_ARTIFACTS_PATH.

I spent a solid two hours figuring out the Expo EAS paths the first time. They're not where you'd expect, and the documentation is... let's say "sparse." Check their docs for the current directory structure — it changes between SDK versions, which is its own kind of frustrating.

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 '@justanalytics/react-native';

export default function App() {
  const navigationRef = useNavigationContainerRef();
  const routeNameRef = React.useRef<string>();

  return (
    <ErrorBoundary>
      <NavigationContainer
        ref={navigationRef}
        onReady={() => {
          routeNameRef.current = navigationRef.getCurrentRoute()?.name;
        }}
        onStateChange={() => {
          const previousRouteName = routeNameRef.current;
          const currentRouteName = navigationRef.getCurrentRoute()?.name;

          if (previousRouteName !== currentRouteName && currentRouteName) {
            JustAnalytics.trackScreen(currentRouteName, {
              previousScreen: previousRouteName,
            });
            routeNameRef.current = currentRouteName;
          }
        }}
      >
        {/* your screens */}
      </NavigationContainer>
    </ErrorBoundary>
  );
}

Now every error includes the current screen name. Filter your error dashboard by screen, see which screens crash most, build retention funnels — all without a separate analytics SDK.

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 @justanalytics/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 --version flag in your upload command matches versionName in your build. I've been burned by this when hotfixes skipped the upload step.

Native crashes appear but have no symbols

dSYM (iOS) or ProGuard mapping (Android) wasn't uploaded. Check your build logs for the upload task. On iOS, Xcode sometimes strips dSYMs in release builds — verify DEBUG_INFORMATION_FORMAT is set correctly.

"Cannot read property 'captureError' of undefined"

You're calling JustAnalytics.captureError before init() finished. The init is async-ish internally. Wrap early calls in a ready check or 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.

Session replay. Mobile session replay is harder than web — no DOM to capture — but the SDK captures touch events, screen transitions, and state changes. Useful for reproducing edge cases. 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, native crashes, source maps, issue grouping — plus analytics, APM, and session replay 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. The SDK handles this automatically — it uploads both the Hermes bytecode source map and the original JS source map during your build. Stack traces are resolved in two passes on our backend, and you see original file names and line numbers.

What about screen analytics — do I need a separate tool?

No. The SDK tracks screen views automatically through React Navigation hooks. 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).

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts