// 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.ts
ts
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.json
json
{
"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.kt
kotlin
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.kt
kotlin
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.kt
kotlin
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.swift
swift
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.h
objective-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.mm
objective-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.ts
ts
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.tsx
tsx
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++.