Native modules, TurboModules and the New Architecture
Write a native module with codegen, understand JSI and Fabric, migrate off the legacy bridge, and decide when native code is worth the maintenance.
A TurboModule with codegen
// specs/NativeBattery.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
getLevel(): Promise<number>;
addListener(eventName: string): void;
removeListeners(count: number): void;
}
export default TurboModuleRegistry.getEnforcing<Spec>('NativeBattery');
// package.json
// "codegenConfig": {
// "name": "NativeBatterySpec",
// "type": "modules",
// "jsSrcsDir": "specs",
// "android": { "javaPackageName": "com.example.battery" }
// }package com.example.battery
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.bridge.ReactContextBaseJavaModule
@ReactModule(name = NativeBatteryModule.NAME)
class NativeBatteryModule(context: ReactApplicationContext) :
NativeBatterySpec(context) {
override fun getName() = NAME
override fun getLevel(promise: Promise) {
val manager = reactApplicationContext.getSystemService(
android.content.Context.BATTERY_SERVICE
) as android.os.BatteryManager
promise.resolve(manager.getIntProperty(
android.os.BatteryManager.BATTERY_PROPERTY_CAPACITY
).toDouble())
}
companion object { const val NAME = "NativeBattery" }
}- Codegen reads the TypeScript spec and generates the native interface, so the signature cannot drift on one side only.
getEnforcingthrows when the module is missing;getreturns null. Prefer the loud failure in development.- TurboModules are loaded lazily on first use, so a module that is never called costs nothing at startup.
- Every native method that can fail should reject the promise with a code and message rather than resolving null.
What the New Architecture changes
| Aspect | Legacy bridge | New Architecture |
|---|---|---|
| Communication | Async batched JSON over the bridge | JSI with direct C++ calls |
| UI | UIManager shadow tree | Fabric renderer with synchronous layout |
| Modules | NativeModule registry | TurboModules, lazily loaded |
| Events | DeviceEventEmitter | Codegen'd event emitters |
| Interop | n/a | Legacy modules still run through an interop layer |
Interop means you can enable the New Architecture before every dependency has migrated. Check each library's release notes for Fabric support, and test on a device — a module that lacks a Fabric component will fail only when its view is rendered.
Should you write native code at all?
// keep the native edge behind a Dart-like facade in TypeScript
export interface Battery {
level(): Promise<number>;
}
export class PluginBattery implements Battery {
async level() {
return NativeBattery.getLevel();
}
}
export class FakeBattery implements Battery {
async level() {
return 0.5;
}
}
export const battery: Battery = __DEV__ && !NativeBattery ? new FakeBattery() : new PluginBattery();⚠️
Every native module is code you must rebuild for each new React Native version, on two or three platforms. Before writing one, search for an existing module, and only build your own when the API you need is genuinely proprietary or unsupported.
FAQ
Do I need to migrate my modules for the New Architecture?
Not necessarily. Legacy modules keep working through the interop layer, but you lose lazy loading and synchronous JSI access, and interop adds overhead on every call. Migrate the hot ones first.
What is JSI actually used for?
It lets C++ hold a reference to a JavaScript object and call into it directly, which is what makes synchronous native calls and high-frequency streams such as gestures viable without a round trip through the old bridge.
Related
Building and releasing with EAS and Fastlane Persistence and device APIs
Last refreshed 2026-09-18.