Sometimes JavaScript is not enough: you need an API only the platform has. The Expo Modules API lets you write that code in Swift and Kotlin with the same shape on both sides, and call it from TypeScript like any other module.
Let's build a small one. It reads the battery level and tells the app when it changes.
## Create the module
In an Expo app, a local module lives in modules/ and is linked on the next native build:
npx create-expo-module@latest --localName it battery. Trimmed to what matters here, it has this shape:
modules/battery/
expo-module.config.json
index.ts
src/BatteryModule.ts
ios/BatteryModule.swift
android/src/main/java/expo/modules/battery/BatteryModule.ktThe config file tells autolinking which class to load on each platform:
{
"platforms": ["apple", "android"],
"apple": { "modules": ["BatteryModule"] },
"android": { "modules": ["expo.modules.battery.BatteryModule"] }
}## Swift
A module is a class with a definition(). Name is what JavaScript asks for, AsyncFunction returns a promise, and Events declares what the module can send:
import ExpoModulesCore
import UIKit
public class BatteryModule: Module {
private var observer: NSObjectProtocol?
public func definition() -> ModuleDefinition {
Name("Battery")
Events("onLevelChange")
AsyncFunction("getLevelAsync") { () -> Float in
UIDevice.current.isBatteryMonitoringEnabled = true
return UIDevice.current.batteryLevel
}.runOnQueue(.main)
OnStartObserving {
UIDevice.current.isBatteryMonitoringEnabled = true
self.observer = NotificationCenter.default.addObserver(
forName: UIDevice.batteryLevelDidChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.sendEvent("onLevelChange", ["level": UIDevice.current.batteryLevel])
}
}
OnStopObserving {
if let observer = self.observer {
NotificationCenter.default.removeObserver(observer)
}
self.observer = nil
}
}
}UIDevice belongs to the main thread, so runOnQueue(.main) runs the function there. On the simulator the level is -1: it has no battery.
## Kotlin
The same definition, word for word, on Android. There is no battery-level callback to observe, so the module listens to the system's ACTION_BATTERY_CHANGED broadcast:
package expo.modules.battery
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class BatteryModule : Module() {
private val context: Context
get() = appContext.reactContext ?: throw Exceptions.ReactContextLost()
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
sendEvent("onLevelChange", mapOf("level" to levelOf(intent)))
}
}
override fun definition() = ModuleDefinition {
Name("Battery")
Events("onLevelChange")
AsyncFunction("getLevelAsync") {
val intent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
intent?.let { levelOf(it) } ?: -1f
}
OnStartObserving {
context.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
}
OnStopObserving {
context.unregisterReceiver(receiver)
}
}
private fun levelOf(intent: Intent): Float {
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
return if (level < 0 || scale <= 0) -1f else level / scale.toFloat()
}
}OnStartObserving and OnStopObserving run when JavaScript adds its first listener and removes its last, so nothing listens while no one is interested.
## TypeScript
One declaration types both platforms:
import { NativeModule, requireNativeModule } from 'expo';
type BatteryEvents = {
onLevelChange(event: { level: number }): void;
};
declare class BatteryModule extends NativeModule<BatteryEvents> {
getLevelAsync(): Promise<number>;
}
export default requireNativeModule<BatteryModule>('Battery');The module's index.ts re-exports it, and a hook hides the subscription:
import { useEffect, useState } from 'react';
import Battery from './modules/battery';
export function useBatteryLevel(): number | null {
const [level, setLevel] = useState<number | null>(null);
useEffect(() => {
let active = true;
Battery.getLevelAsync().then((value) => {
if (active) setLevel(value);
});
const subscription = Battery.addListener('onLevelChange', (event) => setLevel(event.level));
return () => {
active = false;
subscription.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
Expo Go has only the native code it ships with, so a module of your own needs a development build:
npx expo run:ios
npx expo run:androidThat's it: one definition in two languages, and one typed module in JavaScript. The Expo Modules API docs cover views, records and config plugins.