EthicsComputerCourses
Join training +91 90532 10052

Native Modules

React Native · Lesson 24 of 30 Advanced 7 min read Updated

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":

  1. Is there an Expo package? Search the Expo SDK docs (for example expo-battery, expo-haptics, expo-clipboard).
  2. Is there a maintained community library? Check the React Native Directory and its New Architecture badge.
  3. Do you only need to change native settings? For example, a permission string or an Android manifest entry. A config plugin in app.json can do that without writing a module.
  4. 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 blockMeaning
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:

Terminal
npx create-expo-module@latest --local

The 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:

Terminal
npx expo run:android
# or, on a Mac:
npx expo run:ios

Step 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:

Kotlin
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:

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:

TypeScript
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:

React JSX
// 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:

React JSXTry it yourself
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>);
Output
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:android or a new EAS build.
  • Different names on each platform. Name and 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, Constant and Events.
  • npx create-expo-module@latest --local creates a module inside your app's modules/ 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
TypeScript
// 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?

2. Which Expo Modules API block makes a function that returns a promise in JavaScript?

3. Can you test a custom native module in Expo Go?

4. How does JavaScript find the module named ChaiBattery?

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