Week 12: Writing a Native Module (Swift & Kotlin)

Everything so far has been reachable through an Expo package someone already wrote. Occasionally, nothing does what you need — a platform API with no wrapper, a piece of existing native code to reuse. This week writes a real native module from scratch, on both platforms, using the Expo Modules API that makes this dramatically simpler than it used to be.

Phase 6 of 8 Week 12 of 22 ~5 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Understand what a native module is, and when writing one is actually the right call
  • Write a small native module in Swift for iOS using the Expo Modules API
  • Write the same module in Kotlin for Android
  • Call the finished module from TypeScript like any other package

1. Anatomy of a Native Module

A native module is, at its core, a small bridge: native code that exposes a function or a constant to JavaScript. Before writing one, it's worth ruling out the alternative — a JS library already exists for the vast majority of "I need X from the device" needs (as every earlier week in this phase demonstrated). Reach for a native module when the platform API genuinely has no wrapper, or you're bringing in existing native code (a company SDK, a proprietary library) that has to be exposed to the JS side somehow.

terminal — scaffolding a local module
npx create-expo-module@latest --local

This generates a modules/<name> folder with matching Swift and Kotlin starter files, plus the TypeScript glue already wired up — the module below builds on that scaffold.

2. iOS: a Swift Native Module

modules/device-info/ios/DeviceInfoModule.swift
import ExpoModulesCore
import UIKit

public class DeviceInfoModule: Module {
  public func definition() -> ModuleDefinition {
    Name("DeviceInfo")

    Function("getBatteryLevel") { () -> Double in
      UIDevice.current.isBatteryMonitoringEnabled = true
      return Double(UIDevice.current.batteryLevel)
    }

    AsyncFunction("getBatteryLevelAsync") { () -> Double in
      UIDevice.current.isBatteryMonitoringEnabled = true
      return Double(UIDevice.current.batteryLevel)
    }
  }
}

Function exposes a synchronous call; AsyncFunction exposes one that returns a Promise on the JS side — for anything that might genuinely take time (a system call, disk I/O), prefer AsyncFunction so it never blocks the JS thread.

3. Android: the Same Module in Kotlin

modules/device-info/android/src/main/java/.../DeviceInfoModule.kt
package expo.modules.deviceinfo

import android.content.Context
import android.os.BatteryManager
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class DeviceInfoModule : Module() {
  override fun definition() = ModuleDefinition {
    Name("DeviceInfo")

    Function("getBatteryLevel") {
      val context = appContext.reactContext!!
      val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
      val level = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
      level / 100.0
    }
  }
}

Same module name, same function name, same return shape — the whole point of the Expo Modules API is that the JS side never has to know which platform it's running on to call it correctly.

4. Calling It from TypeScript

modules/device-info/index.ts
import DeviceInfoModule from './src/DeviceInfoModule';

export function getBatteryLevel(): number {
  return DeviceInfoModule.getBatteryLevel();
}
using it in a screen
import { getBatteryLevel } from '../modules/device-info';

function BatteryScreen() {
  const [level, setLevel] = useState<number | null>(null);

  useEffect(() => {
    setLevel(getBatteryLevel());
  }, []);

  return <Text>Battery: {level !== null ? Math.round(level * 100) + '%' : '...'}</Text>;
}

From this point, getBatteryLevel() is indistinguishable from a function out of any other package — it's a real requirement of a local native module, though, that a native build (not Expo Go) rebuilds it into the app; a change to the Swift or Kotlin side needs a fresh npx expo run:ios / run:android to take effect.

5. Hands-on Exercise

Hands-on

Write and call your own native module

Scaffold a small native module, implement it on both platforms, and use it from a screen.

Requirements:

  1. Scaffold a local module with create-expo-module --local and pick a small, real capability neither Expo nor a common JS package already exposes cleanly on your device (battery level, a native toast/alert, or similar).
  2. Implement the module in Swift for iOS with at least one Function or AsyncFunction.
  3. Implement the same module in Kotlin for Android with a matching name and signature.
  4. Build the app natively (npx expo run:ios / run:android — this will not work in Expo Go) and call the module from a screen.
  5. Confirm the same TypeScript call works correctly on both platforms, with no platform-specific branching needed at the call site.
Hint

If your new module doesn't show up on the JS side (import resolves to undefined), it's almost always a stale build — local native modules aren't hot-reloadable the way JS is, so a fresh npx expo run:ios or run:android after any native-side change is not optional.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Before writing a native module, what should you check first?

Whether a JS library or an existing Expo package already covers the need — the overwhelming majority of "talk to the device" requirements (as every prior week in this phase showed) are already wrapped. A native module is worth the extra maintenance surface only when no such wrapper exists, or you're integrating existing native code that has no other way in.

Q2

Why prefer AsyncFunction over Function for a native call that might take real time to complete?

Function runs synchronously and blocks the JS thread until it returns — fine for something instant like reading a constant, but a genuinely slow native call (disk I/O, a system query) would freeze the UI. AsyncFunction returns a Promise instead, letting the JS side await it without blocking rendering.

Q3

Why does a change to the Swift or Kotlin side of a local native module not show up after a normal Metro/Fast Refresh reload?

Fast Refresh only re-executes JavaScript — it has no way to recompile and relink native code into the already-running app binary. A native-side change requires a full native rebuild (npx expo run:ios/run:android) to actually take effect, and won't work at all inside Expo Go, which ships a fixed, pre-built native binary.

Q4

Why does the module use the same Name("DeviceInfo") and function name on both the Swift and Kotlin sides?

The Expo Modules API's JS side resolves a module and its functions by these matching names, regardless of which platform is running — using the same name on both means one TypeScript call site works on iOS and Android with no platform branching, which is the entire point of writing the module this way rather than as two separate ad hoc bridges.