Introduction
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:
- Node.js v18 or newer
- Java v17
- 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.
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:
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:
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:
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.
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.
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:
- Add com.facebook.react to the list of your plugins.
- 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.
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.
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.
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.
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.
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:
After:
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.
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.