Writing a Module

Build a native module with the Expo Modules API: one definition block, the same shape in Swift and Kotlin.

SyntaxrequireNativeModule<ModuleType>(name: string): ModuleType

The 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

Example · tsx
// 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: dark

When 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.

Example · swift
// 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.

Example · kotlin
// 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.

Example · tsx
// 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

  • Be the first to comment on this lesson.