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.
  • getEnforcing throws when the module is missing; get returns 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

AspectLegacy bridgeNew Architecture
CommunicationAsync batched JSON over the bridgeJSI with direct C++ calls
UIUIManager shadow treeFabric renderer with synchronous layout
ModulesNativeModule registryTurboModules, lazily loaded
EventsDeviceEventEmitterCodegen'd event emitters
Interopn/aLegacy 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.

Building and releasing with EAS and Fastlane Persistence and device APIs

Last refreshed 2026-09-18.