Writing a Module
Build a native module with the Expo Modules API: one definition block, the same shape in Swift and Kotlin.
requireNativeModule<ModuleType>(name: string): ModuleTypeThe Expo Modules API is the shortest route from Swift or Kotlin to JavaScript. You declare the module in a small definition language and Expo generates the registration, the argument conversion and the promise plumbing. You write the body of each function and nothing else.
Creating one
Run npx create-expo-module@latest --local. It writes modules/<name>/ containing index.ts, an expo-module.config.json, an ios/ folder with Swift and an android/ folder with Kotlin. A local module lives inside your app rather than being published to npm. Autolinking finds it because of that config file, whose platforms array and apple.modules / android.modules entries name the classes to register.
The definition
In Swift you subclass Module and override definition(), which returns a ModuleDefinition. Kotlin has the same shape: class X : Module() with override fun definition() = ModuleDefinition { }. Inside it, Name sets the name JavaScript looks up, Function declares a synchronous call, AsyncFunction one that resolves a Promise, Events declares the event names sendEvent may emit, and Property a getter.
The JavaScript side
requireNativeModule('Name'), imported from expo, returns the native object. Describe its shape with a declare class extending NativeModule so TypeScript knows the methods; a declaration is types only and compiles to nothing.
The usual stumble: Function runs on the JavaScript thread and blocks it. Anything touching disk, the network or a camera belongs in AsyncFunction.
Example
// modules/settings/index.ts
import { NativeModule, requireNativeModule } from "expo";
type ThemeChangeEvent = { theme: string };
type SettingsModuleEvents = {
onChangeTheme: (event: ThemeChangeEvent) => void;
};
// Types only. This declaration describes the native object for TypeScript
// and emits no JavaScript of its own.
declare class SettingsModule extends NativeModule<SettingsModuleEvents> {
getTheme(): string; // Function in the definition: returns immediately
setTheme(theme: string): void;
warmUp(): Promise<void>; // AsyncFunction: resolves a Promise
}
// Looks up the class registered under Name("Settings").
const Settings = requireNativeModule<SettingsModule>("Settings");
export function getTheme(): string {
return Settings.getTheme();
}
export function setTheme(theme: string): void {
Settings.setTheme(theme);
}
// addListener comes from NativeModule; the return value has .remove().
export function addThemeListener(listener: (event: ThemeChangeEvent) => void) {
return Settings.addListener("onChangeTheme", listener);
}
export default Settings;
console.log(getTheme());
// Output: darkWhen to use it
- Expose a payments or analytics SDK that only ships as an iOS framework and an Android AAR.
- Read a platform setting such as the user's preferred theme or accessibility font scale that no library wraps.
- Emit an event to JavaScript when a native callback fires, such as a Bluetooth device appearing.
More examples
The Swift half
The whole iOS side of the module. There is no header file, no bridging macro and no manual method export: the definition block is the contract. Note the two shorthands Swift allows and that the longhand would spell out — a single-expression closure with no return keyword, and a trailing closure passed to Function without parentheses. Both are the same thing as the longer form with an explicit return and an argument label.
// modules/settings/ios/SettingsModule.swift
import ExpoModulesCore
public class SettingsModule: Module {
public func definition() -> ModuleDefinition {
// The string JavaScript passes to requireNativeModule.
Name("Settings")
// Every event name you intend to send must be declared here.
Events("onChangeTheme")
// Function: synchronous. Keep the body cheap.
Function("getTheme") { () -> String in
UserDefaults.standard.string(forKey: "theme") ?? "system"
}
Function("setTheme") { (theme: String) in
UserDefaults.standard.set(theme, forKey: "theme")
self.sendEvent("onChangeTheme", ["theme": theme])
}
// AsyncFunction: the JavaScript caller gets a Promise.
AsyncFunction("warmUp") {
UserDefaults.standard.synchronize()
}
}
}The Kotlin half
The same four declarations in Kotlin, against SharedPreferences instead of UserDefaults. Keeping the two files structurally identical is deliberate: when a function exists on one platform and not the other, JavaScript calling it throws, so matching definitions are the cheapest way to avoid that. The expression-body function with = is shorthand for a block body with return.
// modules/settings/android/src/main/java/expo/modules/settings/SettingsModule.kt
package expo.modules.settings
import android.content.Context
import android.content.SharedPreferences
import androidx.core.os.bundleOf
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class SettingsModule : Module() {
override fun definition() = ModuleDefinition {
Name("Settings")
Events("onChangeTheme")
Function("getTheme") {
return@Function prefs().getString("theme", "system")
}
Function("setTheme") { theme: String ->
prefs().edit().putString("theme", theme).commit()
[email protected]("onChangeTheme", bundleOf("theme" to theme))
}
AsyncFunction("warmUp") {
prefs().getString("theme", "system")
}
}
// appContext is provided by the Expo Modules API, not by you.
private fun prefs(): SharedPreferences =
requireNotNull(appContext.reactContext)
.getSharedPreferences("settings", Context.MODE_PRIVATE)
}Consuming the module and its events
On screen: the current theme name in large type with a Toggle beneath it; each tap writes to native storage, the native side emits onChangeTheme, and the text swaps. The useEffect cleanup matters more here than usual — a native listener is held by native code, so a leaked subscription survives the screen being unmounted. Note useState is called unconditionally at the top, never inside the effect or behind a condition.
// app/ThemeScreen.tsx
import { useEffect, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { addThemeListener, getTheme, setTheme } from "@/modules/settings";
export default function ThemeScreen() {
// Read once on first render; the native value is available synchronously.
const [theme, setLocalTheme] = useState(() => getTheme());
useEffect(() => {
const subscription = addThemeListener(({ theme }) => setLocalTheme(theme));
// Always remove the subscription, or the listener outlives the screen.
return () => subscription.remove();
}, []);
return (
<View style={styles.container}>
<Text style={styles.value}>{theme}</Text>
<Pressable onPress={() => setTheme(theme === "dark" ? "light" : "dark")}>
<Text style={styles.action}>Toggle</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: "center", justifyContent: "center", gap: 12 },
value: { fontSize: 32, fontWeight: "600" },
action: { fontSize: 16, color: "#1f6feb" },
});
Discussion