How to Upgrade React Native in a Brownfield Application

Upgrading React Native in a brownfield app can feel daunting, especially with challenges like dependency mismatches, native integration updates, and debugging build issues. In this post, we’ll walk you through how we upgraded React Native from 0.71 to 0.76 in a brownfield app, sharing tips and common pitfalls along the way. 

If you’re just starting out with brownfield development, check out our step-by-step guide to integrating React Native with an existing app for a solid foundation.

Preparation and Prerequisites

Before starting the upgrade, ensure you have the following in place:

  1. Node.js v18 or newer
  2. Java v17
  3. XCode and Android Studio installed

Node.js 18

Run node -v on your machine to check the installed Node.js version. Ensure it is at least version 18. For React Native apps running version 0.72 or older, the minimum Node.js requirement is version 16. 

However, keep in mind that Node.js 16 reached its end-of-life (EOL) on September 11, 2023. If the node -v output shows a version lower than 18, update Node.js to meet the required standards.

Java 17

Run java -version in your terminal to ensure it’s set to version 17. If you’re upgrading from a version older than 0.73, Java 17 is required to build Android apps. You can install or update to Java 17 by running:

brew install --cask zulu@17

XCode and Android Studio

Xcode and Android Studio are essential for upgrading React Native in a brownfield app because they handle the native build processes for iOS and Android, ensuring compatibility with updated dependencies and platform requirements. 

Without them, debugging native integration issues, managing build configurations, or testing app functionality on emulators and devices becomes nearly impossible. Keeping these tools updated ensures smooth builds and proper error logging.

Align Your Dependencies in ‘package.json’

To begin the upgrade process, let’s align the dependencies in package.json. To simplify this step, you can use @rnx-kit/align-deps, a utility designed to streamline dependency management in React Native projects. It ensures that all dependencies across packages are consistent and compatible by aligning them with a predefined configuration, resulting in more predictable builds and fewer dependency conflicts.

First, add it as a dev dependency to your package.json:

yarn add @rnx-kit/align-deps --dev

Then run the command that will check your dependencies against the 0.76 version and automatically make all the required changes in package.json:

yarn rnx-align-deps --requirements react-native@0.76 --write

Starting from the 0.76 version, the Community CLI is not a direct dependency of React Native anymore. Decoupling React Native from the CLI separates the responsibilities of both projects, so you need to add the Community CLI on your own as a dev dependency.
"dependencies": {
- "react": "18.2.0",
- "react-native": "0.71.11"
+ "react": "18.3.1",
+ "react-native": "0.76.0"
+ },
+ "devDependencies": {
+ "@react-native-community/cli": "15.0.0",
+ "@react-native/metro-config": "0.76.0"
}
}

The code above indicates that you should also add metro-config as a dev dependency. This is required if you are upgrading from the version below 0.72. Additionally, create a metro-config.js file in the root directory of your project and paste the following code:

const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
/**
* @type {import('metro-config').MetroConfig}
*/
const config = {};
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
view raw metro.config.js hosted with ❤ by GitHub

Now it’s a matter of running the install command and moving to upgrading the first platform!

Upgrade the iOS App

Update Your Podfile

Let’s start with making all the required changes in the Podfile. As mentioned before, the Community CLI is not a direct dependency of React Native, so we should get rid of calling the native_modules and call only react_native_pods.rb script. 

Additionally, you should align your app's minimum deployment target with the one supported by the React Native version (for 0.76, it’s 15.1).

If your current React Native version still relies on get_default_flags and the Xcode 12.5 workaround, you should be safe to remove them now. 

Here is the complete diff for Podfile in the Mastodon app:

-require_relative '../node_modules/react-native/scripts/react_native_pods'
-require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules'
+require Pod::Executable.execute_command('node', ['-p',
+ 'require.resolve(
+ "react-native/scripts/react_native_pods.rb",
+ {paths: [process.argv[1]]},
+ )', __dir__]).strip
source 'https://cdn.cocoapods.org/'
-platform :ios, '15.0'
+platform :ios, '15.1'
inhibit_all_warnings!
+config = nil
target 'Mastodon' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
target 'Mastodon' do
# Pods for Mastodon
config = use_native_modules!
- # Flags change depending on the env values.
- flags = get_default_flags()
use_react_native!(
:path => config[:reactNativePath],
- # Hermes is now enabled by default. Disable by setting this flag to false.
- # Upcoming versions of React Native may rely on get_default_flags(), but
- # we make it explicit here to aid in the React Native upgrade process.
- :hermes_enabled => flags[:hermes_enabled],
- :fabric_enabled => flags[:fabric_enabled],
# An absolute path to your application root.
:app_path => "#{Pod::Config.instance.installation_root}/.."
)
end
post_install do |installer|
react_native_post_install(
installer,
- # Set `mac_catalyst_enabled` to `true` in order to apply patches
- # necessary for Mac Catalyst builds
- :mac_catalyst_enabled => false
+ config[:reactNativePath],
+ :mac_catalyst_enabled => false,
+ # :ccache_enabled => true
)
- __apply_Xcode_12_5_M1_post_install_workaround(installer)
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
view raw Podfile.diff hosted with ❤ by GitHub

Update the AppDelegate

The next step is updating the AppDelegate file. Start with inheriting from RCTAppDelegate and calling the didFinishLaunchingWithOptions from it. This delegates the React Native initialization processes to the base class.

Next, customize the RCTAppDelegate by setting the automaticallyLoadReactNativeWindow to false. This step tells React Native that the app is managing the UIWindow, so React Native doesn’t need to handle it.

Also, use sourceURL and bundleURL functions to tell React Native where the JS bundle that needs to be rendered is.

The last thing here is adding an override to all of the application functions. React Native needs to manage certain parts of the app lifecycle while also allowing the native code to function as expected. 

Overriding these methods ensures that React Native and your existing app can coexist and work correctly without interfering with each other.

The full diff for Mastodon app:

import AVFoundation
import MastodonCore
import MastodonUI
+import React
+import React_RCTAppDelegate
@main
-class AppDelegate: UIResponder, UIApplicationDelegate {
+class AppDelegate: RCTAppDelegate {
let appContext = AppContext()
- var window: UIWindow?
- func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
+ override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
AppSecret.default.register()
@@ -41,31 +42,45 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
count += 1 // Int64. could ignore overflow here
UserDefaults.shared.processCompletedCount = count
- return true
+ self.automaticallyLoadReactNativeWindow = false;
+
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// MARK: UISceneSession Lifecycle
+
+ override func sourceURL(for bridge: RCTBridge) -> URL? {
+ self.bundleURL()
+ }
+
+ override func bundleURL() -> URL? {
+ #if DEBUG
+ RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
+ #else
+ Bundle.main.url(forResource: "main", withExtension: "jsbundle")
+ #endif
+ }
- func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
+ override func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
// Called when a new scene session is being created.
// Use this method to select a configuration to create the new scene with.
return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
}
- func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
+ override func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set<UISceneSession>) {
// Called when the user discards a scene session.
// If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
// Use this method to release any resources that were specific to the discarded scenes, as they will not return.
}
- func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
+ override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
return true
}
}
extension AppDelegate {
- func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
+ override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
appContext.notificationService.deviceToken.value = deviceToken
}
}
@@ -98,7 +113,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
// notification present in the background (or resume from background)
- func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any]) async -> UIBackgroundFetchResult {
+ override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any]) async -> UIBackgroundFetchResult {
let shortcutItems = try? await appContext.notificationService.unreadApplicationShortcutItems()
UIApplication.shared.shortcutItems = shortcutItems
return .noData

ReactNativeView Class

Next, create a ReactNativeView class that uses RootViewFactory to create React Native views. The RootViewFactory API is a powerful utility for seamlessly integrating React Native into existing iOS or macOS apps. Leveraging this approach simplifies the transition to the New Architecture and makes upgrading React Native in brownfield apps easier.

+import UIKit
+import React_RCTAppDelegate
+
+class ReactNativeView: UIViewController {
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ let factory = (RCTSharedApplication()?.delegate as? RCTAppDelegate)?.rootViewFactory
+ self.view = factory?.view(withModuleName: "ReactNativeScreen")
+ }
+}

Use ReactNativeView to Navigate to Your React Native Screen

Identify the method triggered when you want your React Native screen to load. In the case of the code snippet below, it’s pressing the button in WelcomeViewController. If you are using RCTRootView directly, replace it with the ReactNativeView class created in the previous step. It’ll navigate to your React Native component. Ensure the module name matches the one registered in your index.js file.

import MastodonLocalization
import MastodonSDK
import React
+import React_RCTAppDelegate
final class WelcomeViewController: UIViewController, NeedsDependency {
-
private enum Constants {
static let topAnchorInset: CGFloat = 20
}
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
+ var reactNativeView: ReactNativeView?
private(set) lazy var authenticationViewModel = AuthenticationViewModel(
context: context,
@@ -276,13 +277,9 @@ extension WelcomeViewController {
//MARK: - Actions
@objc
private func joinDefaultServer(_ sender: UIButton) {
- let jsLocation = RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") { nil }
- let rootView = RCTRootView(bundleURL: jsLocation!, moduleName: "ReactNativeScreen", initialProperties: nil, launchOptions: nil)
-
- let viewController = UIViewController()
- viewController.view = rootView
+ let reactNativeView = ReactNativeView()
- self.navigationController?.pushViewController(viewController, animated: true);
+ self.navigationController?.pushViewController(reactNativeView, animated: true);
}

Upgrade the Android App

Open your native Android app in Android Studio. If you are using the submodules setup mentioned in our guide to brownfield integration, make sure to open the one in your React Native folder. It’s necessary because we’ll use relative paths to point to some files in the node_modules directory.

Update Your Module’s build.gradle

Let’s update the app’s build.gradle file first as per the following steps:

  1. Add com.facebook.react to the list of your plugins. 
  2. Open the lib.versions.toml file in React Native repository and update your configuration parameters according to this file. 
Ensuring your app meets React Native’s requirements prevents compatibility issues between native Android code and React Native. It also allows your app to take full advantage of new features and APIs that React Native depends on. Since React Native is built against specific SDK versions, mismatches can lead to runtime crashes or unsupported behaviors.

Next, we no longer need to apply the native_modules.gradle script. As mentioned earlier, the Community CLI is no longer a direct dependency of React Native, and autolinking is now built into the core. This means you simply call autolinkLibrariesWithApp instead of importing and applying an external script for native modules.

plugins {
id 'com.android.application'
id 'com.google.android.libraries.mapsplatform.secrets-gradle-plugin'
+ id 'com.facebook.react'
}
android {
+ namespace "org.joinmastodon.android"
- compileSdk 33
+ compileSdk 35
defaultConfig {
applicationId "org.joinmastodon.android"
- minSdk 23
- targetSdk 33
+ minSdk 24
+ targetSdk 34
+ ndkVersion = "26.1.10909125"
...
}
githubRelease{
initWith release
}
githubDebug{
initWith debug
}
}
...
-apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project)
+react {
+ autolinkLibrariesWithApp()
+}

Update Gradle and Android Gradle Plugin

First, remove the hardcoded version of the Android Gradle Plugin (AGP) from the main build.gradle file. This allows for more flexibility and centralized version management, enabling automatic resolution of the AGP version based on the environment or other project configurations.

...
dependencies {
- classpath "com.android.tools.build:gradle:7.4.2"
+ classpath "com.android.tools.build:gradle"
...

Second, update the Gradle wrapper version in gradle-wrapper.properties to 8.1 to align it with React Native. It ensures compatibility with newer versions of AGP, improving build performance, and supports for the latest Android development tools.

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-all.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

The last thing is updating your settings.gradle file. We need to include a local build for React Native Gradle Plugin and add com.facebook.react.settings plugin, to configure specific React Native settings in the build process. 

We also need to use autolinkLibrariesFromCommand() to ensure the Gradle project is loading all the discovered libraries. Lastly, we need to update the proper path for React Native Gradle Plugin as it is now a part of the core, and remove the native_modules.gradle script which is not a part of Community CLI anymore.

+pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
+plugins { id("com.facebook.react.settings") }
+extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }
+
dependencyResolutionManagement {
repositories {
google()
@@ -8,5 +12,4 @@ dependencyResolutionManagement {
rootProject.name = "Mastodon"
include ':mastodon'
-includeBuild('../node_modules/react-native-gradle-plugin')
-apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings)
+includeBuild('../node_modules/@react-native/gradle-plugin')

Implement the ReactApplication Interface

First, you should ensure that your main Application class implements the ReactApplication interface. This allows your app to manage the React Native environment alongside the native Android code. You will need to override the getReactNativeHost() method, which is responsible for returning a ReactNativeHost instance.

Additionally, use the SoLoader.init() method to load the shared libraries and native modules used by React Native. Make sure it’s called early in the onCreate() method. Remember to add OpenSourceMergedSoMapping as a second parameter. It’s required because starting from 0.76, React Native introduces libreactnative.so, which merges multiple dynamic libraries into one library, reducing app size and startup time. 

In previous versions of React Native, this parameter was set to false. Without this change, you may encounter numerous errors in dynamic libraries.
@@ -5,13 +5,63 @@ import android.app.Application;
import android.content.Context;
import android.webkit.WebView;
+import com.facebook.react.PackageList;
+import com.facebook.react.ReactApplication;
+import com.facebook.react.ReactHost;
+import com.facebook.react.ReactNativeHost;
+import com.facebook.react.ReactPackage;
+import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
+import com.facebook.react.defaults.DefaultReactHost;
+import com.facebook.react.defaults.DefaultReactNativeHost;
+import com.facebook.react.soloader.OpenSourceMergedSoMapping;
+import com.facebook.soloader.ExternalSoMapping;
+import com.facebook.soloader.SoLoader;
+
import org.joinmastodon.android.api.PushSubscriptionManager;
+import java.io.IOException;
+import java.util.List;
+
import me.grishka.appkit.imageloader.ImageCache;
import me.grishka.appkit.utils.NetworkUtils;
import me.grishka.appkit.utils.V;
-public class MastodonApp extends Application{
+public class MastodonApp extends Application implements ReactApplication {
+
+ @Override
+ public ReactNativeHost getReactNativeHost(){
+ return new DefaultReactNativeHost(this){
+ @Override
+ protected List<ReactPackage> getPackages(){
+ return new PackageList(this).getPackages();
+ }
+
+ @Override
+ protected String getJSMainModuleName(){
+ return "index";
+ }
+
+ @Override
+ public boolean getUseDeveloperSupport(){
+ return BuildConfig.DEBUG;
+ }
+
+ @Override
+ protected boolean isNewArchEnabled(){
+ return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED;
+ }
+
+ @Override
+ protected Boolean isHermesEnabled(){
+ return BuildConfig.IS_HERMES_ENABLED;
+ }
+ };
+ }
+
+ @Override
+ public ReactHost getReactHost() {
+ return DefaultReactHost.getDefaultReactHost(getApplicationContext(), getReactNativeHost());
+ }
@SuppressLint("StaticFieldLeak") // it's not a leak
public static Context context;
@@ -19,6 +69,15 @@ public class MastodonApp extends Application{
@Override
public void onCreate(){
super.onCreate();
+ SoLoader.init(this, OpenSourceMergedSoMapping);
+
+ if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
+ DefaultNewArchitectureEntryPoint.load();
+ }

Create MyReactActivityClass

As a next step, you’ll need to create a new Activity that extends ReactActivity to host the React Native code. This Activity will handle starting the React Native runtime and rendering the React component. By extending ReactActivity, the activity gains the necessary hooks to launch the React Native environment and display the app’s UI.

If you are currently using the implementation of ReactInstanceManager to build the application with all the necessary parameters, you might want to consider switching to this class since it is a very simplified solution.

Before:

package org.joinmastodon.android;
import android.app.Activity;
import android.os.Bundle;
import android.view.KeyEvent;
import com.facebook.react.PackageList;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactPackage;
import com.facebook.react.ReactRootView;
import com.facebook.react.common.LifecycleState;
import com.facebook.react.modules.core.DefaultHardwareBackBtnHandler;
import com.facebook.soloader.SoLoader;
import org.joinmastodon.android.BuildConfig;
import java.util.List;
public class ReactActivity extends Activity implements DefaultHardwareBackBtnHandler {
private ReactRootView mReactRootView;
private ReactInstanceManager mReactInstanceManager;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
SoLoader.init(this, false);
mReactRootView = new ReactRootView(this);
List<ReactPackage> packages = new PackageList(getApplication()).getPackages();
mReactInstanceManager = ReactInstanceManager.builder()
.setApplication(getApplication())
.setCurrentActivity(this)
.setBundleAssetName("index.android.bundle")
.setJSMainModulePath("index")
.addPackages(packages)
.setUseDeveloperSupport(BuildConfig.DEBUG)
.setInitialLifecycleState(LifecycleState.RESUMED)
.build();
mReactRootView.startReactApplication(mReactInstanceManager, "ReactNativeScreen", null);
setContentView(mReactRootView);
}
@Override
protected void onPause() {
super.onPause();
if (mReactInstanceManager != null) {
mReactInstanceManager.onHostPause(this);
}
}
@Override
protected void onResume() {
super.onResume();
if (mReactInstanceManager != null) {
mReactInstanceManager.onHostResume(this, this);
}
}
@Override
protected void onDestroy() {
super.onDestroy();
if (mReactInstanceManager != null) {
mReactInstanceManager.onHostDestroy(this);
}
if (mReactRootView != null) {
mReactRootView.unmountReactApplication();
}
}
@Override
public void onBackPressed() {
if (mReactInstanceManager != null) {
mReactInstanceManager.onBackPressed();
} else {
super.onBackPressed();
}
}
@Override
public boolean onKeyUp(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_MENU && mReactInstanceManager != null) {
mReactInstanceManager.showDevOptionsDialog();
return true;
}
return super.onKeyUp(keyCode, event);
}
@Override
public void invokeDefaultOnBackPressed() {
super.onBackPressed();
}
}

After:

package org.joinmastodon.android;
import com.facebook.react.ReactActivity;
import com.facebook.react.ReactActivityDelegate;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactActivityDelegate;
public class MyReactActivity extends ReactActivity {
@Override
protected String getMainComponentName() {
return "ReactNativeScreen";
}
@Override
protected ReactActivityDelegate createReactActivityDelegate() {
return new DefaultReactActivityDelegate(this, getMainComponentName(), DefaultNewArchitectureEntryPoint.getFabricEnabled());
}
}

If an activity class name is different than in the past, remember to update its name in AndroidManifest.xml and when creating an intent to start the activity.

Enable New Architecture

Now add two parameters to your gradle.properties file to enable New Architecture in your project.

+reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
+newArchEnabled=true
+hermesEnabled=true

Test Your Integration

Open your apps in Xcode and Android Studio. After upgrading React Native, it's crucial to run your app on both platforms—iOS and Android—to ensure that the upgrade hasn’t introduced any regressions or issues specific to each platform. 

Verify core functionalities - ensuring that these features work properly helps identify if any fundamental issues were introduced during the upgrade process. Test it carefully on both platforms, as they might require updates to native code or dependencies.

Keep an eye on any new warnings or errors in the console, such as deprecation warnings or runtime errors. These may not always cause immediate crashes but could point to future issues or missing dependencies that need addressing. Ensure that errors are fixed or logged properly to maintain app stability.

Challenges You May Face Along the Way

Unfortunately, very often the upgrade process in a brownfield app is not something that happens in a day or two. It takes a lot of time to upgrade complex apps. You might encounter some challenges that you might need to solve. 

Here are some examples of popular issues to deal with when upgrading React Native in a brownfield setup:

Breaking Native Dependencies

When upgrading, you'll often face dependency resolution conflicts because RN's internal pods might conflict with your existing native dependencies. This gets worse if you have libraries using static linking while others use dynamic, or if some pods require specific Swift versions.

From Android's perspective, its build chain is particularly sensitive to version mismatches. The config needs to be in sync with React Native and your Android libraries. A common nightmare scenario is when one library requires AGP 7.x while another needs 8.x.

Ensure that all your native libraries and third-party packages are up to date and work with the new version.

Breaking Changes

Each version of React Native comes with some breaking changes that might affect your setup. Fortunately, these changes are always well described in the Release notes. Check the changelog for any deprecations or API changes that may affect your code, especially with native modules.

If your upgrade requires major bumps of any libraries, follow their Release notes as well. It might save you a lot of time trying to solve some issues that are not part of React Native.

Performance Regressions

Performance regressions or improvements after an update may not always be obvious at first glance, which is why regression testing becomes critical. 

Set up automated performance tests that can be run after every upgrade to catch any regressions. For consistency, measure the performance before and after the upgrade under the same conditions.

Memory leaks can introduce slowdowns over time. Use profiling tools to track memory usage.

How to Mitigate Problems When Upgrading

If your app was previously running on the Old Architecture, I highly recommend upgrading to it first. Once you’ve ensured it works properly, you can enable the New Architecture, as it may introduce new types of errors.

If you are upgrading from a very old version of React Native, it is recommended to upgrade gradually instead of jumping straight to the latest version. 

For example, if your app is running on React Native 0.70, it might be a good idea to upgrade to a couple of earlier versions before jumping straight to 0.76. Thanks to that, it is easier to verify at what stage some errors happen, and the difference between versions is easier to understand and apply. The React Native Upgrade Helper is an invaluable tool when it comes to that.

Final Notes

Upgrading React Native in a brownfield app is a complex process that demands attention to detail, careful planning, and thorough testing. By following the steps outlined in this article, you can facilitate the process and minimize potential pitfalls. 

Just bear in mind that every application has its unique challenges, so leverage tools like the Upgrade Helper, and don’t rush through critical testing phases. 

With a methodical approach, your upgrade will not only ensure compatibility but also unlock the latest features and performance improvements React Native has to offer.

Insights

Learn more about Brownfield

cover

Aug 27Article

React Native in .NET MAUI: A Practical Brownfield Integration Guide

Learn how to embed React Native inside a .NET MAUI app with a brownfield setup. This guide covers runtime packaging, lifecycle wiring, navigation, and shared state across both runtimes.

cover

Aug 3Article

Migration to React Native in 2026 Starts With a Delivery Question

Choosing between brownfield and greenfield React Native migration? Learn where risk sits, when to use a checkpoint, and what to prove before committing.

cover

Jul 8Article

How we made Hybrid iOS apps compile 39% faster with React Native Brownfield

See how React Native Brownfield uses iOS prebuilts to cut packaging times by up to 39% for hybrid apps, with details on setup, internals, and benchmark results.

cover

Jun 18Article

How We Made the Brownfield Gradle Plugin 22% Faster

See how task-driven Gradle optimization made the Brownfield Gradle Plugin 22% faster for clean Android release builds.

cover

Jun 3Article

AI-Supported Brownfield Migration to React Native

See how AI-supported workflows cut brownfield React Native evaluation from weeks to days by speeding up setup, planning, implementation, and QA.

cover

May 18Article

Unlocking Expo Updates in an Isolated Brownfield Architecture with SDK 55

Learn how to enable Expo Updates in an Isolated Brownfield setup with Expo SDK 55, including iOS packaging, Android patching, and OTA testing.

cover

Apr 23Talk

State of Brownfield in React Native

A practical look at Brownfield React Native adoption in mature native apps, architecture, tooling, and real-world tradeoffs.

cover

Apr 2Webinar

AI-Assisted Migrations to React Native: From Months to Days

Reduce migration time from months to days by using AI in real React Native workflows. Learn when to migrate incrementally and when starting fresh is the better option.

cover

Nov 17Talk

Vega Developer Meetup

Learn how to integrate React Native into Android TV apps at the Vega Developer Meetup. Workshop, roadmap, and community talks, live in Munich.

cover

May 16Podcast

You Don’t Need a Rewrite, You Need React Native Brownfield

Discover how modern React Native Brownfield integration enables cross-platform power in native apps, without rewriting from scratch.

cover

Jan 13Podcast

Lessons from Shopify's Migration to React Native with Mustafa Ali

Learn how Shopify's migration to React Native led to 86% code unification, reduced build times, and seamless feature parity across platforms.

cover

Jun 14Podcast

Migration to React Native

The 13th episode of React Universe On Air (formerly known as The React Native Show) is fully focused on migrating from different technologies to React Native, the two most popular approaches to this process (greenfield and brownfield software development), and our state-of-the-art brownfield approach.

cover

Mar 19Tutorial

The Hard Way vs. The React Native AI SDK Way: Stop Writing Custom Modules for Every Model

Learn how React Native AI uses a unified provider model to switch between cloud and on-device LLMs without rewriting application logic, enabling reliable AI experiences online and offline.

cover

Mar 12Tutorial

Build Smarter Apps: Tool Calling & AI Orchestration Explained

Learn how on-device models in React Native can call tools, interact with external APIs, and return structured outputs you can use directly in application logic.

cover

Mar 5Tutorial

MLC LLM + React Native: On-Device AI Without the Pain

Learn how to run third-party on-device LLMs in React Native using MLC LLM. Choose your models, run them efficiently across platforms, and keep a unified JavaScript API with React Native AI.

cover

Feb 24Tutorial

Intelligent Fallbacks with Apple Intelligence in React Native

Learn how to use Apple’s built-in Foundation Models in React Native apps, reduce memory usage, and enable fast, fully on-device AI with React Native AI Apple.

cover

Feb 10Tutorial

What Is the React Native AI SDK? A Complete Intro & Quickstart

Learn why on-device AI matters for React Native apps, how local LLMs behave offline, and what problems React Native AI is designed to solve from day one.

cover

Jan 29Tutorial

Implementing an Android TurboModule with Kotlin

Learn how to implement a React Native TurboModule on Android, from the TypeScript spec and Codegen to the Kotlin implementation and package registration.

cover

Jan 19Tutorial

Working with Different Threads in Swift TurboModules

Learn how JavaScript, native module, background, and main threads interact in React Native TurboModules, and how to use GCD queues to run async work safely in Swift.

cover

Jan 9Tutorial

How to Add Type-Safe Constants to Swift TurboModules

Learn how to expose typed, immutable constants from a Swift TurboModule using Codegen, and get full end-to-end type safety between native and JavaScript.

cover

Dec 30Tutorial

Adding Event Emitters to Your TurboModule in Swift

Learn how to emit native events from a Swift TurboModule and subscribe to them in JavaScript, including proper memory management and Codegen integration.

cover

Dec 22Tutorial

Writing Your First TurboModule in Swift

Learn how to build a fully working TurboModule in Swift, integrate it with Codegen, and bridge it through Objective‑C inside an Expo app.