Table of contents
By Alan Slater
This second article in an expert content series explores React Native internals we need to understand deeper when stepping off the standard path
So, you’re a developer or technical lead tasked with integrating React Native into an existing native app? Our content series is just what you need to successfully navigate this journey.
In part I of our series, we discussed the planning stage: understanding the scale of the challenge, essentials that should be in place before you start and some viable strategies to carry out the task.
Hopefully your organisation has such a plan in place, and now you’re at the point of adding React Native to the existing Android app: something that looks deceptively simple from the documentation, and can be simple if you’re lucky, but can be extremely challenging if you are not.
Going off-road
Conceptually, React Native can be thought of as a React-oriented JavaScript parser wrapped in Android and iOS modules, which then control the pieces of those native apps. For many React Native developers on conventional React Native projects, this is how it is experienced: install React Native, feed TypeScript or JavaScript into the black box, sprinkle some debugging, iteration, sweat and caffeine, and iOS and Android apps come out.
When we replace the preconfigured React Native Android and iOS shell apps with something fundamentally different and as complex and idiosyncratic as an established app, we can no longer rely on the pieces in the React Native black box just clicking into place. And when there are problems, those problems may be unique to this app integration, meaning there may be no documentation or existing search results to fall back on.
We’ll be going off-road, and we may need to fit pieces of React Native to things they weren’t exactly designed to fit. It’ll therefore help to first look under the hood and take some time to understand what those pieces of the React Native black box actually are.
What is React Native, under the hood?
React Native’s core function is to read JavaScript, and do React-like render cycles, that control native APIs and components, as efficiently as reasonably possible.
This core functionality is done in a selection of libraries, written by Meta/Facebook in C++, shipped as .so
libraries, and then built from source for each binary type your app supports. Here’s an overview of some of the elements of the React Native ‘black box’ that you may need to deal with directly:
1: Read JavaScript:
JavaScript (JS) engine: there are two options and it’s possible for an app to use one on Android and another on iOS:
Hermes is React Native’s own purpose-built JS engine and the new default since React Native 0.70 for both iOS and Android (September 2022).
JavaScriptCore (JSC) is Apple’s own JavaScript engine, included in iOS and used in Apple apps like Safari and Mail. It (and an Android port) was used by older versions of React Native, and may be used in newer versions if Hermes is disabled.
Debugger (optional): Flipper binaries are bundled in React Native installs by default and (if enabled using build settings), in development they allow Meta/Facebook’s Flipper desktop debugging app direct communication with the JavaScript engine. Other React Native debuggers are available but only Flipper has supporting binaries bundled in React Native itself.
2: React-like render:
Layout engine: Yoga translates React flexbox-like trees inspired by the CSS flex model into Android and iOS equivalent layout styles.
Surface renderer: there are two options and it’s possible for an app to use one on Android and another on iOS:
Fabric (if “New Architecture” is enabled, not yet default in 2023), uses a C++ core and code generation to let React Native’s JavaScript engine communicate directly and synchronously with native app components, enabling some improvements to performance, stability, and support for modern React patterns like concurrency and suspense. Desirable, but not everything in the React Native ecosystem supports Fabric yet, and it adds another native module to app builds.
Android Native Components and iOS Native Components (sometimes called “Old Architecture Components” or “Legacy Native Components”) used platform-specific controllers and asynchronous JSON-based messaging from JavaScript. This may be more stable and easier to integrate than Fabric in some contexts, at the cost of being less smooth where performance demands are highest (differences may often be not noticeable).
3: Control native APIs:
Bridging from JavaScript: there are two options and it’s possible for an app to use one on Android and another on iOS:
TurboModules (if “New Architecture” is enabled, not yet default in 2023) uses the JSI API to allow native code and JavaScript to communicate directly via a C++ core bridging module and code generation.
Old Architecture Native Modules (sometimes called “Legacy Native Modules”) communicate using event listeners and by serialising and deserialising async JSON messages.
4: Efficiency:
Shared libraries: Folly bundles many Facebook/Meta C++ general-purpose utilities used by React Native and other projects. It’s extremely rare to need to interact with it directly, but errors sometimes originate from it.
Loading: SoLoader is a Facebook/Meta system for dynamically choosing binary dependencies (like almost all of the above) in a backwards-compatible way based on the needs of the device. It relies on the build having created different types of binary that it can pick from and read, which can cause hard-to-debug runtime errors if the build succeeded but didn’t provide exactly what SoLoader expected.
Bridging: CodeGen creates statically-typed bridging code between libraries that use different type systems, and is mainly used in “New Architecture” apps to connect custom app logic from type-safe JavaScript to endpoints in various React Native internal C++ libraries.
Wait, you keep saying C++…
Because TypeScript, JavaScript, Kotlin, Java, Objective-C, Swift, XML, Groovy and Ruby weren’t enough languages for one app project?
Seriously though, having core React Native libraries as binaries produced by C++ is good for efficiency and performance, and it’s exceptionally rare to need to debug C++ in React Native (unless you choose to, for writing highest-performance native “turbo-modules”).
When wiring in a native app, however, you might need to troubleshoot the steps where React Native’s own C++ .so
libraries are built and loaded, so it helps to know what these are:
Android
Handles C++ builds via the Android NDK, or “Native Development Kit” — yes, it’s yet another new context for the word “Native”.
When thinking from a React Native perspective, we think of Java and Kotlin as “native” Android code, but when we step inside the /android
directory, everything is “native Android” in this sense.
Within Android development “native” generally refers to binaries built from C++ by NDK: these are ‘even more native’ than Android’s native Java or Kotlin, because it’s pure machine code that bypasses the Java virtual machine.
All of this is normally automatically in the build, but since NDK is a distinct tool, we might need to ensure that NDK is present and the right version is used — more on that when we deep-dive Android builds in part III of our series.
iOS
Handles C++ out of the box. This is because Objective-C can run C++ directly, so iOS’s Pods dependency management system can ship or import C++ without needing extra steps or tooling.
More on React Native’s New Architecture
Another thing you may have noticed from the list of React Native internals is that (as of 2023) React Native is in a transition period, moving from “Old Architecture” to “New Architecture”. There’s a lot written about the New Architecture, but the key theme is as follows:
Old Architecture is oriented around pinging async JSON messages between React Native’s JavaScript and native-side code.
New Architecture shifts as much as possible to raw binary and endeavours to enable direct, synchronous communication with native APIs where possible.
At the time of writing (React Native 0.72), there are essentially two “New Architecture” switches that you’ll need to choose how to flip:
Whether to use the Hermes JavaScript engine (on by default)
Using Hermes is highly advisable. This is not just because it is mature, the default and more performant, but also because it is much better for debugging, with accurate stack traces for JavaScript-side errors replacing notoriously vague indications in the old architecture that would sometimes point to the wrong file (e.g. if errors occur in hooks).
The Fabric rendering engine (off by default)
It also enables TurboModules. This is also desirable as it gives you more options and potentially much better performance for how you can connect your app’s legacy native features with React Native.
However, since it is not yet the default, it comes with the caveat that (as of 2023) some parts of the React Native ecosystem have not yet fully supported it, or their support is work-in-progress. A notable example is React Native Reanimated, which supports the new architecture in versions 3.x, but (at the time of writing) this branch is quite new, with some stability issues and challenges with wiring it into a non-standard app (compared to the older but more established 2.x branch).
This balance will of course change: Fabric will become the default, support for it will improve and stabilise (and modules that don’t will be supplanted by newer and better rivals), and eventually support for the Old Architecture will be dropped.
If your required modules support Fabric already, and you can get the build configuration to work, taking the plunge is advisable; but using the Old Architecture in the short term might be a necessary compromise.
These switches are platform-specific
Another thing to keep in mind is that these switches are platform-specific. If necessary, you could have Old Architecture on Android and New Architecture on iOS, or vice versa, or mix and match Hermes and Fabric between platforms. Consistency is obviously desirable, if possible, but mixed configurations are valid and may help if exceptionally difficult build conflicts are encountered.
Build toolchains
On iOS
iOS’s build toolchain is conceptually pretty simple. XCode (either the desktop application, CLI version, or XCode Cloud) handles the main build, and CocoaPods handles the dependencies. As with many things by Apple, it is purpose-built and often Just Works™, but sometimes Just Fails™ with opaque messages (especially where anything diverges from the happy path, or, after tooling updates).
React Native follows this pretty conventionally, with the only major difference being its autolinking system that pulls in pods from node_modules
.
On Android
Android uses Gradle for builds, which is a multi-purpose language-agnostic build system used in many projects and languages beyond Android and Java. Android’s Gradle setup is therefore more of a patchwork, with configuration spread across many files, using many plugins and delegated build tools (like NDK).
Gradle manages the whole build, but there are more independent moving parts under the hood, more places your native app may have diverged from standards presumed by React Native. It means that there more potential pitfalls.
Making it work
Each native app is very different, so wiring them in will have very different needs. The next parts of this series will look at Android and iOS in turn:
Part 3 will focus on Android, arguably the more challenging due to the greater number of moving parts in its build process.
Part 4 will focus on iOS, conceptually simpler but sometimes harder to debug, being designed more closely around a conventional “happy path”
Does your organisation want to cut its time to market, lower its maintenance costs and improve its product reliability? Incorporating React Native into your organisation delivers these outcomes and more. If you’d like to adopt React Native, get in touch. We’d love to discuss how we can help.