React Native Land_

// building better mobile apps

# Native code with Turbo Modules

2026-11-12#mug

In the last post we built a battery module with the Expo Modules API. Let's build the same one with what React Native itself ships: a Turbo Native Module. It reads the battery level and tells the app when it changes.

The difference starts with where the truth lives. Here, a TypeScript spec describes the module, and Codegen turns it into native interfaces that Kotlin and Objective-C++ have to implement. If they don't match, the build fails.

## The shape

In a React Native app, trimmed to what matters here:

specs/NativeBattery.ts
package.json
ios/RCTNativeBattery.h
ios/RCTNativeBattery.mm
ios/BatteryLevel.swift
android/app/src/main/java/com/nativebattery/NativeBatteryModule.kt
android/app/src/main/java/com/nativebattery/NativeBatteryPackage.kt

## The spec

The spec's file name must start with Native. It declares a promise and a typed event:

specs/NativeBattery.tsts
import type { CodegenTypes, TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export type BatteryEvent = {
  level: number;
};

export interface Spec extends TurboModule {
  getLevel(): Promise<number>;
  readonly onLevelChange: CodegenTypes.EventEmitter<BatteryEvent>;
}

export default TurboModuleRegistry.getEnforcing<Spec>('NativeBattery');

package.json tells Codegen where the specs are, the Java package to generate into, and which Objective-C class provides the module:

package.jsonjson
{
  "codegenConfig": {
    "name": "NativeBatterySpec",
    "type": "modules",
    "jsSrcsDir": "specs",
    "android": { "javaPackageName": "com.nativebattery" },
    "ios": { "modulesProvider": { "NativeBattery": "RCTNativeBattery" } }
  }
}

## Kotlin

Codegen writes an abstract class, NativeBatterySpec, with one method per function in the spec and an emitOnLevelChange for the event. The module extends it:

android/.../NativeBatteryModule.ktkotlin
package com.nativebattery

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext

class NativeBatteryModule(reactContext: ReactApplicationContext) : NativeBatterySpec(reactContext) {
  private val receiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
      emitOnLevelChange(Arguments.createMap().apply { putDouble("level", levelOf(intent)) })
    }
  }

  override fun getName() = NAME

  override fun initialize() {
    super.initialize()
    reactApplicationContext.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
  }

  override fun invalidate() {
    reactApplicationContext.unregisterReceiver(receiver)
    super.invalidate()
  }

  override fun getLevel(promise: Promise) {
    val intent = reactApplicationContext.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
    promise.resolve(intent?.let { levelOf(it) } ?: -1.0)
  }

  private fun levelOf(intent: Intent): Double {
    val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
    val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
    return if (level < 0 || scale <= 0) -1.0 else level / scale.toDouble()
  }

  companion object {
    const val NAME = "NativeBattery"
  }
}

There is no first-listener hook as in Expo Modules, so the receiver lives as long as the module: from the first time JavaScript asks for it until the app reloads.

A package hands the module to React Native when JavaScript asks for its name:

android/.../NativeBatteryPackage.ktkotlin
package com.nativebattery

import com.facebook.react.BaseReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider

class NativeBatteryPackage : BaseReactPackage() {
  override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
    if (name == NativeBatteryModule.NAME) NativeBatteryModule(reactContext) else null

  override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
    mapOf(
      NativeBatteryModule.NAME to ReactModuleInfo(
        name = NativeBatteryModule.NAME,
        className = NativeBatteryModule.NAME,
        canOverrideExistingModule = false,
        needsEagerInit = false,
        isCxxModule = false,
        isTurboModule = true,
      )
    )
  }
}

And MainApplication.kt adds it to the list autolinking builds:

MainApplication.ktkotlin
override fun getPackages(): List<ReactPackage> =
  PackageList(this).packages.apply {
    add(NativeBatteryPackage())
  }

## Swift

Codegen writes Objective-C++ protocols, which Swift cannot adopt. The battery code can still be Swift: a small class, marked @objc so Objective-C sees it:

ios/BatteryLevel.swiftswift
import UIKit

@objc public class BatteryLevel: NSObject {
  private var observer: NSObjectProtocol?

  @objc public static var current: Float {
    UIDevice.current.isBatteryMonitoringEnabled = true
    return UIDevice.current.batteryLevel
  }

  @objc public func start(_ onChange: @escaping (Float) -> Void) {
    UIDevice.current.isBatteryMonitoringEnabled = true
    observer = NotificationCenter.default.addObserver(
      forName: UIDevice.batteryLevelDidChangeNotification,
      object: nil,
      queue: .main
    ) { _ in
      onChange(UIDevice.current.batteryLevel)
    }
  }

  @objc public func stop() {
    if let observer {
      NotificationCenter.default.removeObserver(observer)
    }
    observer = nil
  }
}

## Objective-C++

The module itself is a thin Objective-C++ class. Its header adopts the generated protocol, and extends the generated base class that brings emitOnLevelChange:

ios/RCTNativeBattery.hobjective-c
#import <NativeBatterySpec/NativeBatterySpec.h>

NS_ASSUME_NONNULL_BEGIN

@interface RCTNativeBattery : NativeBatterySpecBase <NativeBatterySpec>
@end

NS_ASSUME_NONNULL_END

The implementation forwards to Swift. UIDevice belongs to the main thread, so both calls go there; on the simulator the level is -1, since it has no battery:

ios/RCTNativeBattery.mmobjective-cpp
#import "RCTNativeBattery.h"
#import "BatteryApp-Swift.h"

@implementation RCTNativeBattery {
  BatteryLevel *_battery;
}

+ (NSString *)moduleName {
  return @"NativeBattery";
}

- (instancetype)init {
  if (self = [super init]) {
    _battery = [BatteryLevel new];
    __weak RCTNativeBattery *weakSelf = self;
    dispatch_async(dispatch_get_main_queue(), ^{
      [weakSelf->_battery start:^(float level) {
        [weakSelf emitOnLevelChange:@{@"level": @(level)}];
      }];
    });
  }
  return self;
}

- (void)dealloc {
  [_battery stop];
}

- (void)getLevel:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
  dispatch_async(dispatch_get_main_queue(), ^{
    resolve(@(BatteryLevel.current));
  });
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
    (const facebook::react::ObjCTurboModule::InitParams &)params {
  return std::make_shared<facebook::react::NativeBatterySpecJSI>(params);
}

@end

BatteryApp-Swift.h is the header Xcode writes for the app's Swift code; its name is your target's.

## TypeScript

The spec is already the typed module, so the hook imports it directly. An event is a function that takes a listener and returns a subscription:

useBatteryLevel.tsts
import { useEffect, useState } from 'react';
import NativeBattery from './specs/NativeBattery';

export function useBatteryLevel(): number | null {
  const [level, setLevel] = useState<number | null>(null);

  useEffect(() => {
    let active = true;
    NativeBattery.getLevel().then((value) => {
      if (active) setLevel(value);
    });
    const subscription = NativeBattery.onLevelChange((event) => setLevel(event.level));
    return () => {
      active = false;
      subscription.remove();
    };
  }, []);

  return level;
}

The badge is the same as before:

BatteryBadge.tsxtsx
import { Text } from 'react-native';
import { useBatteryLevel } from './useBatteryLevel';

export function BatteryBadge() {
  const level = useBatteryLevel();
  if (level === null || level < 0) return <Text>Battery: unknown</Text>;
  return <Text>Battery: {Math.round(level * 100)}%</Text>;
}

## Run it

Codegen runs when the pods are installed and when Gradle builds, so a change to the spec needs both again:

cd ios && bundle exec pod install && cd ..
npx react-native run-ios
npx react-native run-android

## Which one?

  • The spec is the contract: Codegen checks the native side against it at build time, and the module is typed in JavaScript with no declaration of your own.
  • Turbo Modules need no dependency beyond React Native, and can be written in C++ once for both platforms.
  • Expo Modules ask for less code: no package, no registration, no Objective-C++, Swift all the way, and hooks for the first and last listener.

The React Native docs on Turbo Native Modules go further, including modules written in C++.