Skip to content

Adding React Native to a Complex App — Part 3: Android

This third article in an expert content series takes a deep dive into the technical details involved in adding React Native to an existing Android app

So, you’re a developer working on adding React Native to an existing complex app, and you’re currently looking at Android? Our content series is here to help you successfully complete this journey.

Previously in our series:

  • In part 1 of this guide, we looked at planning: you should have a clear, considered strategy-

  • In part 2 of this guide, we looked under the hood of React Native: hopefully you’ve got a basic understanding of the pieces we’re working with.

1. Preparation

We’ll need to do a lot of delicate surgery in the Android app’s build process, so preparation is key.

1.1 Prerequisites

Before starting, you should:

  1. Have at least a basic familiarity with Android projects in Android Studio. You don’t need to be an Android expert. However, it’ll help a huge amount if you already know your build.gradle from your app/build.gradle, and if you’re comfortable using Android Studio to navigate Android files: its quirky Project view really does help when you’re used to it (tip: enable “Always select open file”), and its “Problems” and “Logcat” windows, in particular, will be invaluable. Google’s App Basics guides are a good place to start.

  2. Have the React Native Android development environment fully set up, and check your version of Android Studio is recent.

1.2 Have working ‘clean’ projects for comparison

It’s a very good idea to keep clean, working examples of the two projects you intend to merge:

Working native app

Have a separate repo, fork or clone with the native app as it is without React Native. Get this set up, built and confirmed to work and keep that working project as a comparison. You don’t want to spend hours debugging a build error, only to find out the problem was with something on your machine and unrelated to your actual work.

Working React Native app

Also, have a minimal working React Native app build of the intended React Native version, with a few required native dependencies installed (particularly, common low-level dependencies like react-native-screens , react-native-gesture-handler and react-native-reanimated if you intend to use these).

This way you’ll be able to see much more clearly what issues came from the project merge:

  • If there are problems with your local developer setup, or required preparation steps for the native app, or other non-merge-related issues, you’ll catch them before adding additional complexity.

  • If/when you encounter issues, it’s easy to compare your current working branch with two complete configurations you know worked.

1.3 Fragment or Activity?

On Android, you can inject React Native into an Activity , or a Fragment (or multiple of either, or both…):

An Activity is like a full-screen window

In a simple conventional React Native app, everything React Native is often entirely within the one “Main” Activity, with maybe a custom splash screen as the only other Activity in the app.

Many older Android apps clunked between dozens of different Activities when navigating between screens. However, many modern native Android apps only open a new Activity when navigating to something that feels like a fundamentally different sub-app, like when a videoconferencing app launches a video call.

A Fragment can sit within an Activity and doesn’t need to be full screen

It’s like a regular view, but smarter, directly plugged into the application context and its position in the navigation history. They’re what react-native-screens uses under the hood for navigation screens, and most times you see an app with tabs or drawers (in native Android or in React Native), those screens under the tabs and drawers are implemented as Fragments.

This is a crucial decision!

In part 1 of this series , we discussed how this decision interplays with some of the strategies that can be taken for adding React Native to a native app. Planning this right is important!  Don’t proceed unless you have a clear understanding of how this will work: projects can get in a frightful mess if they start lobbing in activities or fragments without a clear, coherent plan.

2. Making it build

Once you’re clear on your approach, it’s time to replace your React Native project’s ./android directory with the Android native app, make a commit in a new branch to easily track and revert changes from the original, clean native app, and begin the deceptively short React Native “Integration with Existing Apps” guide. If you’re using Fragments, start with the same guide but switch to the Integration with an Android Fragment  guide when it starts talking about ReactRootView .

Here are some common problems to look out for.

 2.1 Variants, modes, packages and flavours

In a conventional React Native app, your bundle ID matches your app ID and your package name (e.g. ), the main activity is called MainActivity (in e.g.  package ), and it has “debug” and “release” modes, and that’s that. React Native assumes this pattern is followed unless told otherwise.

Longstanding Android apps are often a lot more complicated. A company might have reused code or assets by writing multiple separate apps into one project (sometimes even one app directory), switching between apps using “flavors” like customer , agent , salesdeck for wholly different apps with a little shared code.

There may be many build environments, including multiple pre-release intermediaries like staging and UAT, and all this would be selected from the Android Studio “Build variants” window with camelcase concatenated names like “customerDev”, “agentStaging”, “salesdeckUAT”.

An app variant might be listed on the Play Store with a simple bundle ID like that dates back to its very first release, but after years of rewrites and modernisation initiatives, you might find its current main activity is named Launchpad2Activity in package biz.revampInitiative2017.customerApp.ux.screens , and debug builds are generated with app IDs like .

2.1.1 Declare your “debuggable variants”

If you use build variants with names other than “debug” and “release”, React Native needs to know which should be treated like “debug” (load JavaScript on the fly via Metro, enable debugging features) and which should be treated like “release” (bundle minified JavaScript as a static resource, disable debugging features). By default, it treats all variants as “release” unless their name includes the substring “ debug ”.

You need to add a react block to app/build.gradle , with a debuggablePlugins key with an array of the full  variant names that should run React Native in “debug” mode, as listed in the Build Variants window. For example (as of React Native 0.71):

Plain Text
apply plugin: "com.facebook.react"

react {
  debuggableVariants = ["customerDev", "agentDev"]
  // any more config specifically for React Native Gradle Plugin can go here

This is documented , but only deep in the general docs for the React Native Gradle Plugin.

2.1.2 Fixing the Metro appName

React Native assumes that every build has the same unique app ID which is the same as what will be released to Play Store, but many Android projects use different names or suffixes for pre-release variants. If this is the case, once a build succeeds, your local React Native Metro server may simply fail to connect and send the debug app any JavaScript because it doesn’t recognise the name of the app trying to connect to it.

Tell Metro the actual app ID of the app that it should send your JavaScript to by creating or updating a react-native.config.js file with:

module.exports = {
  project: {
    android: {
      // How the built app identifies itself to Metro etc.
      // You may need to pull in env variables to vary this by build types.
      appName: '',

2.1.3 Fixing npm run android

If your app uses build variants, npm run android isn’t going to work without configuration, and npm run start then a in the terminal is never going to work.

You could commit to only running dev builds through Android Studio, or if you want to use the React Native CLI, head to the React Native CLI run android documentation  and write customised scripts, for example:

scripts: {
    "android": "react-native run-android --main-activity biz.revampInitiative2017.ux.customerApp.screens.Launchpad2Activity --variant customerDev --appIdSuffix v3.devInternal --appId",

2.1.4 What’s the main activity name?

You may see errors like this, indicating your --main-activity flag is wrong:

“error: Activity class MainActivity does not exist”

If the full name of your app’s main activity isn’t obvious, open the AndroidManifest.xml and look for the block that contains an that contains . That’s the activity block describing your app’s main activity, and its android:name is its name (and also, in Android Studio, cmd-click on the name takes you to the activity’s file).

If the name starts with a . then it’s an incomplete name suffix: to get the full name, it needs to be added to the app’s package namespace, which you’ll find either:

  • In app/build.gradle’s namespace key under the android block


  • In AndroidManifest.xml in the package key of the <manifest tag

For example, an AndroidManifest.xml like this:

<?xml version="1.0" encoding="utf-8"?>
  <!-- here's the package namespace if it's not in `app/build.gradle` like: -->
  <!--   android {                                       -->
  <!--     namespace "biz.revampInitiative2017.ux"       -->
  <manifest ...
      <!-- lots of permissions, application, services... -->
      <!-- here's the activity name, to append to the package namespace -->
      <activity ...
          <!-- this is what identifies this activity as the "main" one: -->
          <action android:name="android.intent.action.MAIN" />
          <category android:name="android.intent.category.LAUNCHER" />

The above implies a main activity name like biz.revampInitiative2017.ux.customerApp.screens.Launchpad2Activity .

2.2 Gradle plugins

Every step of the Android build, from pulling in dependencies to compilation, is handled by Gradle, launched via /gradlew (or /gradle.bat on Windows), then configured in build.gradle files. Your project has a top-level build.gradle responsible for setting up the build environment, then running many nested builds — most importantly, app/build.gradle to build the app code, but also many React Native dependencies and Android dependencies contain their own build.gradle (much like how many JS dependencies have package.json with their own post-install scripts), with various dependencies, plugins and build scripts.

2.2.1 Cleaning and re-syncing

Note that any time you change any Gradle files, if you’re using Android Studio, you’ll need to “re-sync” before it’ll warn you of issues.

Sometimes you’ll also need to run gradlew clean ( gradle.bat clean on Windows) to run a cleanup command (clears the build directory, plus any custom cleanup added to your native app’s gradlew runner). You can save yourself some time with an NPM command like:

"android:clean": "cd android && gradlew clean && cd .."

2.2.2 AGP versions

The most important of the many Gradle plugins is the “AGP” (“Android Gradle Plugin” or ). Everything, from different Android Studio versions to lines of build script logic, will have been written with particular version ranges of the AGP in mind, and, unfortunately, this sometimes gets flakey. For example, see this issue on React Native 0.71’s issues with AGP prior to 7.4.1 .

But first, the easy part — if you see an error like this:

“The project is using an incompatible version (AGP 7.4.1) of the Android Gradle plugin. Latest supported version is AGP 7.3.1”

Your version of Android Studio is incompatible, so, change your version of Android Studio. Your local dev environment is the easiest, most side-effect-free thing to change: don’t change the config to match your personal tools, change your tools to match the config. Google has a table of Android Studio and AGP version compatibility .

The challenge comes if React Native and your native app need different versions. It’s a good idea to take the versions that work in your clean builds, and keep both, with one commented out and comments on where they’re from, to help immediate and future debugging, and to try to persist with whichever is newer (so long as it’s in the same major range), for example:

Plain Text
dependencies {
        // classpath '' // from native app
        classpath '' // from React Native

Here, there’s a good chance that it’ll work — with a slim, but non-zero chance that the native app contains some quirky action that depends on the side effect of a bug fixed after 7.2.1, or a rarely used feature accidentally regressed.

The problems will come if either React Native or the native app need different major versions, and the version required by one breaks the other:

Plain Text
dependencies {
    // There's probably some change from v6-v7 that the native app isn't ready for:
    // classpath '' // from native app
    classpath '' // from React Native

It’s not possible to mix versions. There’s probably no simple alternative here to biting the bullet and updating the native app project to adapt to these breaking changes. The good news is, this is a common enough task that there are several resources available, including:

  • The official AGP Upgrade Assistant, which can fix most issues automatically in Android Studio

  • Extensive documentation on breaking changes in each past AGP release

  • Extensive documentation on Gradle version compatibility, with upgrade guides for specific version bumps (because upgrading the Android Gradle Plugin probably also implies upgrading the version of Gradle itself that will handle the build).

2.3 Duplicate and clashing dependencies

Unlike NPM, which can resolve multiple versions of the same dependency (although sometimes this causes side effects which make us wish it wouldn’t…), Android’s Gradle builds require just one version of each dependency in the final build.

This could pose a problem if your native app and React Native require conflicting versions of a shared nested dependency, or if one includes static files of a dependency another pulls down from a repository.

There are three ways to analyze your dependency tree and find the cause of clashes or duplicates:

2.3.1 Gradle’s dependency analyzer

You can use Gradle’s CLI to generate a dependency tree. In the /android directory, run ./gradlew :app:dependencies to see trees for every build mode. If your native app has many build variants or flavours, you can filter this with the --configuration flag that takes one of the headers of the full output, which are made from camel case concatenation of a complete build variant name plus a “classpath” name (this will usually be RuntimeClasspath , browse the full verbose without the --configuration flag to see alternatives). For example:

  • cd android && ./gradlew :app:dependencies --configuration debugRuntimeClasspath && cd ..

  • ./gradlew :app:dependencies --configuration customerStagingRuntimeClasspath

2.3.2 Android Studio’s “Analyze Dependencies”

You can right-click app in the Android Project view then choose “Analyze -> Analyze Dependencies”. This has a UI, but it is quite difficult to use, requiring a lot of digging through complex deep-nested trees and subtrees, and the initial analysis required to populate it is very slow.

2.3.3 Fixing nested dependencies

Once you find the root cause of the dependency error, you’ll need to either align versions of the top-level dependency, remove one top-level dependency, or if neither of these simple fixes is an option, you can force a dependency to use a particular version of a nested dependency by adding a resolutionStrategy block  like this just above the dependencies block in the build.gradle file that contains the top-level dependency with the problematic nested dependency:

Plain Text
// add this...
configurations.all {
  resolutionStrategy {
    force 'some.problematic.nested:dep:1.2.3'
// ...above this
dependencies {
  implementation 'some.other.dependency:worksFine:9.8.7'

  // example bundle requires 'some.problematic.nested:dep:1.0.1', clashes with React Native
  implementation 'some.big.bundle:ofDependencies:3.4.5'

An alternative, more hands-on option is to disable transitive dependencies of a dependency completely with the { transitive = false } option. This maximises control but requires you to do a lot of extra work, manually specifying all nested dependencies:

Plain Text
dependencies {
  implementation 'some.other.dependency:worksFine:9.8.7'

  // example bundle requires 'some.problematic.nested:dep:1.0.1', clashes with React Native
  implementation 'some.big.bundle:ofDependencies:3.4.5' {
    transitive = false
  implementation 'some.problematic.nested:dep:1.2.3' // required by `some.big.bundle`
  implementation 'another.nested.dep:here:2.3.4' // also required by `some.big.bundle`
  implementation 'yet.another.nested:dep:3.4.7' // also required by `some.big.bundle`

2.4 Check for Problems

Android Studio has a “Problems” window that highlights warnings and errors across the project and is worth watching. By default, it only looks at the current file, but its “Project Errors” tab will show any major problems from anywhere, including files you’ve never touched.

The first thing to check any time you see problems reported, is whether there is a banner along the top reminding you that some Gradle config changed and it needs to re-sync. This easy (but easy-to-forget) step will fix a lot of problems you see.

2.4.1 Unexpected type errors after a config change

Problems can sometimes emerge after a re-sync, in unexpected places such as deep in native app logic you’ve never touched. A common cause here is if a change in the version of a dependency or SDK causes some typing detail to change, which caused a cascade of tiny changes that ended in a type error. For example, an argument from some method in a library may have changed between “optional” and “required”, and seven steps down from where that library is used, some native code might start failing type checks because now, it sees a variable that could be null passed somewhere that can’t handle null .

Most issues like this should be possible to fix with some tweaks to either abort the process if the variable is null, or to provide a typesafe default. Kotlin’s “Elvis operator” ( ?: - roughly equivalent to ?? in JavaScript) is very useful here, as is this pattern:

Plain Text
optionalVariable?.let {
  // `it` in this block is `optionalVariable` but guarenteed not null.
  // This whole block is skipped if it is null

2.4.2 “Cannot resolve symbol” errors

You might see errors like this in the Problems window:

“cannot resolve symbol 'PackageList'”
“cannot resolve symbol 'buildConfig’”

The good news for these is, they’re not real problems. Both of these are generated during the first build, so do a build, and the warnings should just go away.

2.5 Integrating Kotlin

Kotlin is a great language to work with, but it is an extra compilation and complication in your build. Most React Native Android packages were historically written in Java, and most React Native Android documentation assumes the native app is overwhelmingly Java, but most modern Android development is done mostly in Kotlin.

2.5.1 Failures to import Kotlin from Java

You might unexpectedly see errors like “Cannot find symbol” or “Package does not exist” during a build or Gradle sync. Confusingly, this error may have never appeared in your Problems window, and this exact setup might have worked fine a day ago, or for a colleague with the same setup.

More confusingly, if you try to debug this in Android Studio, the error takes you to a file where the imported class or package quite clearly does exist — probably deep in some part of your native app that has always worked fine and which you haven’t touched.

How do you debug this (when the code looks fine, worked fine, and you can’t even search on it because the problems are specific to your app)?

Take a closer look: are all the problems in Kotlin files imported from Java files?

If so, the problem is likely to do with timing in your build. Something is parsing Java before the Kotlin files have been compiled into a form Java can understand.

Check the order of the Kotlin-related dependencies and plugin calls in your build.gradle files. Perhaps you did something like this:

Plain Text
apply plugin: ''
apply plugin: "com.facebook.react" // you added this before kotlin
apply plugin: 'kotlin-android'

This could cause the React plugin to look down the tree of Java files that contain React imports, and if these have dependencies on Kotlin files, it’ll fail to load them. Crucially, it might work at first  if a recent build left compiled versions of those Kotlin files in a cache, then seemingly randomly, it stops working when the cache expires or when a colleague pulls down your commit from a repository.

Make sure your Kotlin-related plugins are as early as possible everywhere they’re defined, usually immediately after the generic Android ones.

3. Making it run

Getting a working build is a vital step, but there are a number of sources of possible native-side runtime errors that could see your app instantly crash to the home screen, or silently stop before any React Native code becomes visible.

3.1 Stop React Native being optimised away

We know React Native is essential to the app, but Android tooling that follows code paths defined in Java and Kotlin might have no way of seeing that essential React Native dependencies will be used by code invoked from C++ or JavaScript, and may strip it out.

3.1.1 Code shrinking with ProGuard / R8

Almost all Android apps use ProGuard or R8 to remove unused code, similar to treeshaking in JS web projects. Note that ProGuard and R8 use largely the same configuration files and options, so while R8 has largely replaced ProGuard under the hood  since around 2019 (AGP version 3.4), you’ll still mostly work with ProGuard files.

If your project contains a file (probably under “Gradle Scripts” in Android Studio), add something like this to the end. If you get errors about other specific classes being unexpectedly missing, add them too in a similar way:

Plain Text
# Keep React Native / hermes:
-keep class** { *; }
-keep class com.facebook.jni.** { *; }
# If New Architecture is enabled add this too:
-keep class com.facebook.react.turbomodule.** { *; }

These are some known React Native libraries that R8/ProGuard often incorrectly thinks are unused, because they’re called from native binary or JavaScript that it can’t follow. Android’s Code Shrinking docs  have additional troubleshooting steps if these don’t work.

3.1.2 Dynamic Feature Modules

Android can compartmentalise a whole app module, complete with dependencies and Java/Kotlin code, and treat it as optional, downloaded on the fly via Play Feature Delivery  if a user activates that feature.

Unfortunately, this doesn’t seem to play nicely with React Native’s features for bundling device-specific C++ code and seems to result in runtime errors due to missing libraries.

If React Native being in a dynamic feature module is a strict requirement, follow issues like this  for updates. However, at the time of writing it looks like at least the core React Native dependencies need to be ever-present in the default build.

3.1.3 Third-party code obfuscation, like DexGuard

Some high-security apps go another level, and use tools to obfuscate and/or encrypt their source code, often to protect internal assets on rooted devices or try to prevent reverse engineering.

If your app uses these, the good news is, DexGuard , probably the most widely used such tool, talks about having supported React Native since 2019 . The bad news is, there’s still a lot that can go wrong. In particular, while obfuscation tools tend to do a good job encrypting and decrypting the code itself, they often change filenames and other such meta details that tools like React Native might be hardcoded to expect to be a certain way (for example, ).

Since DexGuard and similar alternatives are generally closed-source paid products, there’s a strong chance you’ll need to contact their technical support about any issues if your app uses it.

3.2 Properly allow ClearText traffic

React Native’s guide includes an instruction to add android:usesCleartextTraffic="true" to the AndroidManifest <application> tag in debug builds, to allow JavaScript to be loaded from your local machine’s dev Metro server in development mode.

This may not be enough if your app has <network-security-config> declared (probably somewhere in app/src/main/res/xml — or do a project-wide search for network-security-config ), as this finer-grain configuration overrides the above flag. If so, add a block like this (be sure to either remove it in production builds or scope it to non-production build variants):

<domain-config cleartextTrafficPermitted="true">
  <domain includeSubdomains="true">localhost</domain>
  <domain includeSubdomains="true"></domain>
  <domain includeSubdomains="true"></domain>

 3.3 Fixing SoLoader failures

As discussed in part 2 of this series , React Native uses a lot of C++ libraries and uses a homespun Facebook/Meta tool, SoLoader, to load these at runtime based on the needs of the host device. This can cause extremely difficult to debug runtime failures, with error messages like:

“java.lang.UnsatisfiedLinkError: couldn’t find DSO to load:”


“java.lang.UnsatisfiedLinkError: dlopen failed: cannot locate symbol "__emutls_get_address"”

These issues have a very similar meaning: __emutls_get_address is generally the very first symbol accessed in a modern .so library, so both errors suggest that either one or more (probably, all) .so C++ libraries haven’t been prepared and bundled correctly for the current device, or, they’ve been built by something too old that predates __emutls_get_address .

3.3.1 Pinning NDK version

The most important piece of this puzzle is the NDK ( also discussed in Part 2 ). It needs to be correctly versioned in at least two places:

1: The top-level ./build.gradle needs to specify ndkVersion for the whole project, like this:

Plain Text
android {
    // This picks up the value from the project-level build.gradle.
    // Lots of internal React Native dependencies have gradle config doing the same.
    ndkVersion rootProject.ext.ndkVersion

    // more android config values...

These lines of config fix the version used for the whole project then explicitly pass that version to the Android Gradle Plugin during the app stage of the build. Without this, a default NDK version may be chosen that might not be suitable and might not match the version used internally within React Native dependencies that have their own Gradle build steps that use the NDK.

3.3.2 Check for conflicts

__emutls_get_address usually comes specifically from a bundled library. React Native usually manages the sourcing and bundling here itself, but if your native app comes with its own, older file, or has very strict requirements, React Native’s attempts to pick a suitable may be replaced by this file which might lack this expected __emutls_get_address symbol.

Search your native app folders for hard copies of this file, or for build rules like pickFirst that might force a particular version to be used, and if this does exist, see if it’s strictly necessary: it may be that this was added long ago to force what was then a relatively new version, and that forcing the newer version expected by React Native will work just as well.

3.3.3 JNI bundling options

If it still doesn’t work, the issue could be with JNI (Java Native Interface), which bridges between the Java / Kotlin Android system and the raw machine code produced by the C++ .so libraries.

Start by searching your build.gradle files for jniLibs settings — for example, the native app might have an over-broad jniLibs.excludes pattern that causes React Native libraries to be excluded.

If your app’s minSdkVersion is higher than 23, it may be worth trying adding useLegacyPackaging as follows, which forces all .so files to be compressed into the built bundle. At the time of writing, React Native’s minSdkVersion is 21, where this behaviour is the default, but if your native app requires a higher minimum version, the default will change. Adding this flag will revert it to the behaviour React Native expects:

Plain Text
android {
    packagingOptions {
        jniLibs {
            useLegacyPackaging = true

3.3.4 Ensure React Native and SoLoader are up to date

Unfortunately, Facebook/Meta’s homespun SoLoader library is itself buggy and has issues where it behaves incorrectly for certain Android architectures.

In particular:

If your version of React Native is below 0.71.5, upgrade (following guidance on any required native changes) . You can also ensure an up-to-date version of SoLoader is being used by:

  1. Using the dependency debugging tools described in section 2.2.3 above and searching the output for com.facebook.soloader:soloader

  2. If it’s old, forcing a recent version to be used by adding a block like this to your app build.gradle:

Plain Text
configurations.all {
  resolutionStrategy {
    // pin a version after the bug fix you want to ensure is picked up
    force 'com.facebook.soloader:soloader:0.10.5+'

3.4 Routine, ongoing debugging

Hopefully, after all of this, the app should build and run as expected. If you encounter further issues, here’s a few tips to keep in mind:

  • Don’t forget to look for errors in Android Studio’s LogCat window, since React Native debugging tools won’t help if the error is before React Native even starts

  • Don’t forget to scroll up through the LogCat firehose because often, the error with the useful debugging information is a long way before the fatal error that caused the crash

  • Keep an eye on Android Studio’s “Problems” window which should flag any issues that emerge within the app’s Java and Kotlin logic

  • Good luck!

In part 4 , we’ll look at troubleshooting specific to iOS apps.

Does your organisation want to get the benefits of React Native?

Our experts can help incorporate React Native into your organisation. If your organisation wants to reduce its time to market, cut its maintenance costs and boost its product reliability, contact us today . We’d love to help level up your organisation.

Insight, imagination and expertly engineered solutions to accelerate and sustain progress.