Native Modules
By the end of this lesson you will be able to decide when your app really needs native code, create a local native module with the Expo Modules API, write one function in Kotlin and Swift, and call it from JavaScript with a safe fallback.
Most apps never need custom native code. Expo's libraries and community packages cover the camera, location, storage, notifications, payments and much more. But one day you may need something no library offers, such as a feature of a particular Bluetooth billing printer. That is when native modules come in.
When do you need one?
Work down this list and stop at the first "yes":
- Is there an Expo package? Search the Expo SDK docs (for example
expo-battery,expo-haptics,expo-clipboard). - Is there a maintained community library? Check the React Native Directory and its New Architecture badge.
- Do you only need to change native settings? For example, a permission string or an Android manifest entry. A config plugin in
app.jsoncan do that without writing a module. - None of the above? Write a native module.
It is like cooking at a wedding. First check if the caterer already has the dish. Then ask a known sweet shop. Only cook it yourself if nobody else can.
What a native module is
A native module is a piece of Kotlin (Android) or Swift (iOS) code that exposes functions to JavaScript. With the New Architecture, these are TurboModules under the hood (see The New Architecture).
Expo provides the Expo Modules API, a friendlier way to write them. You describe the module with a short list of building blocks, and Expo writes the connecting code for you:
| Building block | Meaning |
|---|---|
Name("...") | The name JavaScript uses to find the module |
Function("...") | A synchronous function that returns a value immediately |
AsyncFunction("...") | A function that returns a promise in JavaScript |
Constant("...") | A fixed value, read once |
Events("...") | Events the module can send to JavaScript listeners |
Step 1: create a local module
Run this in your project folder:
npx create-expo-module@latest --localThe tool asks for a name; call it chai-battery. It creates modules/chai-battery/ with an android/ folder, an ios/ folder, an index.ts file and an expo-module.config.json file. A local module lives inside your app, so you do not publish it to npm.
Native code means you need a development build, not Expo Go (see Set Up React Native with Expo). After adding or changing native code, rebuild:
npx expo run:android
# or, on a Mac:
npx expo run:iosStep 2: write the Android side in Kotlin
Open modules/chai-battery/android/src/main/java/.../ChaiBatteryModule.kt and define one function that returns the battery percentage:
package expo.modules.chaibattery
import android.content.Context
import android.os.BatteryManager
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class ChaiBatteryModule : Module() {
override fun definition() = ModuleDefinition {
Name("ChaiBattery")
Function("getLevel") {
val manager = appContext.reactContext?.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
return@Function manager?.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) ?: -1
}
}
}BatteryManager is Android's own battery service. If anything is missing, the function returns -1 instead of crashing.
Step 3: write the iOS side in Swift
Open modules/chai-battery/ios/ChaiBatteryModule.swift:
import ExpoModulesCore
import UIKit
public class ChaiBatteryModule: Module {
public func definition() -> ModuleDefinition {
Name("ChaiBattery")
Function("getLevel") { () -> Int in
UIDevice.current.isBatteryMonitoringEnabled = true
let level = UIDevice.current.batteryLevel // 0.0 to 1.0, or -1 if unknown
return level < 0 ? -1 : Int(level * 100)
}
}
}Both sides use the same name, ChaiBattery, and the same function name, getLevel. That shared contract is what lets one line of JavaScript work on both phones. The iOS Simulator has no real battery, so expect -1 there.
Step 4: call it from JavaScript
In modules/chai-battery/index.ts, load the native module by name:
import { requireNativeModule } from "expo";
type ChaiBatteryModule = {
getLevel(): number;
};
export default requireNativeModule<ChaiBatteryModule>("ChaiBattery");Then use it in a component. The modules folder sits next to src, so this file in src/components imports it with a relative path:
// src/components/battery-badge.jsx
import { Text } from "react-native";
import ChaiBattery from "../../modules/chai-battery";
export function BatteryBadge() {
const level = ChaiBattery.getLevel();
return <Text>{level >= 0 ? `Battery ${level}%` : "Battery unknown"}</Text>;
}What you should see on an Android phone: a line such as "Battery 76%". On the iOS Simulator: "Battery unknown". Because getLevel is a Function, not an AsyncFunction, it returns the number immediately, with no await.
Practise the logic: a JavaScript wrapper
Wrapping the native module in a small JavaScript layer gives you one place for fallbacks and formatting. It also lets you swap in a fake module for tests or the web. This wrapper is plain JavaScript, so try it here with two fake modules:
import { createRoot } from "react-dom/client";
function createBatteryService(nativeModule) {
return {
level() {
if (!nativeModule) return null; // e.g. running on the web
const value = nativeModule.getLevel();
return value >= 0 && value <= 100 ? value : null;
},
label() {
const value = this.level();
if (value === null) return "Battery unknown";
if (value <= 15) return `Low battery (${value}%). Save your bill before it dies!`;
return `Battery ${value}%`;
},
};
}
const phone = createBatteryService({ getLevel: () => 12 });
const simulator = createBatteryService({ getLevel: () => -1 });
const web = createBatteryService(null);
[phone, simulator, web].forEach((s) => console.log(s.label()));
createRoot(document.getElementById("root")).render(<p>{phone.label()}</p>);Low battery (12%). Save your bill before it dies! Battery unknown Battery unknown
The screen never talks to the native module directly. It calls the service, which handles every odd case in one tested place.
Native views and publishing
The Expo Modules API can also expose native views, such as a map or a video player written in Kotlin and Swift, which you then render like any component. And if your module is useful to others, npx create-expo-module@latest without --local creates a standalone package you can publish to npm.
Common mistakes
- Writing native code too early. Check Expo packages, community libraries and config plugins first.
- Testing in Expo Go. Custom native code needs a development build.
- Forgetting to rebuild. JavaScript changes reload instantly; native changes need
npx expo run:androidor a new EAS build. - Different names on each platform.
Nameand function names must match exactly on Android and iOS. - Crashing on missing data. Return a safe value (like -1 or
null) and handle it in JavaScript.
Key points
- Use native modules only when no package or config plugin does the job.
- The Expo Modules API defines modules with
Name,Function,AsyncFunction,ConstantandEvents. npx create-expo-module@latest --localcreates a module inside your app'smodules/folder.- Write matching Kotlin and Swift code, then load it with
requireNativeModule("Name"). - Native code needs a development build and a rebuild after changes.
- Wrap native modules in a small JavaScript service for fallbacks and testing.
Exercise
Extend the ChaiBattery module design with an AsyncFunction named isCharging that resolves to true or false. Write the TypeScript type for the module and a JavaScript function chargingMessage() that awaits it and returns "Charging" or "On battery", returning "Unknown" if the call throws.
Show solution
// modules/chai-battery/index.ts
import { requireNativeModule } from "expo";
type ChaiBatteryModule = {
getLevel(): number;
isCharging(): Promise<boolean>;
};
const ChaiBattery = requireNativeModule<ChaiBatteryModule>("ChaiBattery");
export default ChaiBattery;
export async function chargingMessage(): Promise<string> {
try {
return (await ChaiBattery.isCharging()) ? "Charging" : "On battery";
} catch {
return "Unknown";
}
}On the native side you would add AsyncFunction("isCharging") { ... } in both Kotlin and Swift. Because it is asynchronous, JavaScript receives a promise and must await it.
Quick Quiz
1. What should you check before writing a native module?
Native modules add build and maintenance work, so use them only when needed.
2. Which Expo Modules API block makes a function that returns a promise in JavaScript?
AsyncFunction results reach JavaScript as promises.
3. Can you test a custom native module in Expo Go?
Expo Go contains a fixed set of native code; custom modules need your own build.
4. How does JavaScript find the module named ChaiBattery?
requireNativeModule looks up the module by the name given in Name(...).
Learn React Native with a mentor
Free lessons get you started. Our Mobile App Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
