We built a battery module with Expo Modules and with Turbo Modules. Let's build it once more with Nitro Modules, from Margelo. It still reads the battery level and tells the app when it changes.
Nitro keeps the Turbo Modules idea, a TypeScript spec that generates native interfaces, but its generator, Nitrogen, writes them for Swift and Kotlin directly. No Objective-C++ in between, no package to register by hand.
## Create the module
Nitrogen writes a library template with the build files already wired:
npx nitrogen@latest init react-native-batteryTrimmed to what matters here, it has this shape:
react-native-battery/
nitro.json
src/specs/Battery.nitro.ts
src/index.ts
ios/HybridBattery.swift
android/src/main/java/com/margelo/nitro/battery/HybridBattery.kt
nitrogen/generated/nitro.json names the module's namespaces and which class implements each spec on each platform:
{
"cxxNamespace": ["battery"],
"ios": { "iosModuleName": "NitroBattery" },
"android": {
"androidNamespace": ["battery"],
"androidCxxLibName": "NitroBattery"
},
"autolinking": {
"Battery": { "swift": "HybridBattery", "kotlin": "HybridBattery" }
}
}## The spec
A Nitro module is a hybrid object: a TypeScript interface, implemented natively, that JavaScript holds like any other object. The spec says which language implements it on each platform:
import type { HybridObject } from 'react-native-nitro-modules';
export interface Battery extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> {
getLevel(): Promise<number>;
addListener(onChange: (level: number) => void): () => void;
}There are no events. A callback is a function like any other: JavaScript passes one to addListener, and native code hands back another that removes it. A method can also return a value at once, with no promise, when the native side has it at hand.
Nitrogen turns the spec into a Swift protocol and a Kotlin abstract class, both named HybridBatterySpec:
npx nitrogenIts output goes in nitrogen/generated/. Run it again whenever the spec changes.
## Swift
The class implements the generated protocol, nothing more. number is Double on both platforms, and a spec method may throw:
import NitroModules
import UIKit
class HybridBattery: HybridBatterySpec {
func getLevel() throws -> Promise<Double> {
return Promise.async {
await MainActor.run {
UIDevice.current.isBatteryMonitoringEnabled = true
return Double(UIDevice.current.batteryLevel)
}
}
}
func addListener(onChange: @escaping (_ level: Double) -> Void) throws -> () -> Void {
DispatchQueue.main.async {
UIDevice.current.isBatteryMonitoringEnabled = true
}
let observer = NotificationCenter.default.addObserver(
forName: UIDevice.batteryLevelDidChangeNotification,
object: nil,
queue: .main
) { _ in
onChange(Double(UIDevice.current.batteryLevel))
}
return {
NotificationCenter.default.removeObserver(observer)
}
}
}UIDevice belongs to the main thread, so MainActor.run reads it there. A Nitro callback can be called from any thread; Nitro brings it back to JavaScript's. On the simulator the level is -1: it has no battery.
## Kotlin
The same two methods on Android, each listener with a broadcast receiver of its own:
package com.margelo.nitro.battery
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import androidx.annotation.Keep
import com.facebook.proguard.annotations.DoNotStrip
import com.margelo.nitro.NitroModules
import com.margelo.nitro.core.Promise
@Keep
@DoNotStrip
class HybridBattery : HybridBatterySpec() {
private val context: Context
get() = NitroModules.applicationContext ?: throw IllegalStateException("React Native is not ready")
override fun getLevel(): Promise<Double> = Promise.async {
val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
intent?.let { levelOf(it) } ?: -1.0
}
override fun addListener(onChange: (level: Double) -> Unit): () -> Unit {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
onChange(levelOf(intent))
}
}
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
return { context.unregisterReceiver(receiver) }
}
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()
}
}@Keep and @DoNotStrip stop R8 from removing a class that only native code refers to. Nothing listens unless JavaScript asked: the receiver lives from addListener until the function it returned is called.
## TypeScript
The module's entry creates the hybrid object by the name in nitro.json:
import { NitroModules } from 'react-native-nitro-modules';
import type { Battery } from './specs/Battery.nitro';
export const battery = NitroModules.createHybridObject<Battery>('Battery');The hook is the one from before, with the listener's own function to unsubscribe:
import { useEffect, useState } from 'react';
import { battery } from 'react-native-battery';
export function useBatteryLevel(): number | null {
const [level, setLevel] = useState<number | null>(null);
useEffect(() => {
let active = true;
battery.getLevel().then((value) => {
if (active) setLevel(value);
});
const remove = battery.addListener((value) => setLevel(value));
return () => {
active = false;
remove();
};
}, []);
return level;
}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
The app needs Nitro's runtime beside the module, then a native build:
npm install react-native-nitro-modules ./react-native-battery
cd ios && bundle exec pod install && cd ..
npx react-native run-ios
npx react-native run-android## Three ways, one battery
- Expo Modules: the least code. One definition per platform, Swift and Kotlin, hooks for the first and last listener, and nothing to generate.
- Turbo Modules: nothing beyond React Native. The spec is checked by Codegen at build time, but iOS goes through Objective-C++, and Android needs a package registered by hand.
- Nitro Modules: a spec like Turbo Modules, implemented straight in Swift and Kotlin. Callbacks are plain functions, and a hybrid object is an object, so there can be many. The cost is a dependency and a generator to run yourself.
The Nitro docs go further: hybrid views, objects passed between modules, and modules in C++.