Skip to main content
Expo Modules API provides a modern, type-safe way to write native modules in Swift (iOS) and Kotlin (Android) with minimal boilerplate.

Overview

Expo modules offer several advantages:
  • Type safety: Automatic type conversions between JS and native
  • Modern syntax: Swift and Kotlin instead of Objective-C and Java
  • Auto-generated bindings: No manual bridge code
  • Shared module API: Similar API across iOS and Android
  • Built-in features: Events, views, lifecycle methods

Creating a Module

Project Structure

modules/
└── my-module/
    ├── android/
    │   └── src/main/java/expo/modules/mymodule/
    │       ├── MyModule.kt
    │       └── MyModulePackage.kt
    ├── ios/
    │   └── MyModule.swift
    ├── src/
    │   └── index.ts
    ├── expo-module.config.json
    └── package.json

Initialize Module

# Create module from template
npx create-expo-module my-module

# Or manually
cd modules
mkdir my-module
cd my-module
npm init

Configuration

expo-module.config.json
{
  "platforms": ["ios", "android"],
  "ios": {
    "modules": ["MyModule"]
  },
  "android": {
    "modules": ["expo.modules.mymodule.MyModule"]
  }
}
package.json
{
  "name": "my-module",
  "version": "1.0.0",
  "main": "src/index.ts",
  "expo": {
    "platforms": ["ios", "android"]
  }
}

iOS Module (Swift)

Basic Module

ios/MyModule.swift
import ExpoModulesCore

public class MyModule: Module {
  public func definition() -> ModuleDefinition {
    // Module name
    Name("MyModule")

    // Synchronous function
    Function("hello") { (name: String) -> String in
      return "Hello, \(name)!"
    }

    // Async function
    AsyncFunction("fetchData") { (url: String) -> [String: Any] in
      let data = try await URLSession.shared.data(from: URL(string: url)!).0
      let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
      return json
    }

    // Function with callback
    Function("compute") { (value: Int, callback: @escaping (Int) -> Void) in
      DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        callback(value * 2)
      }
    }
  }
}

Constants

public func definition() -> ModuleDefinition {
  Name("MyModule")

  // Export constants
  Constants([
    "API_URL": "https://api.example.com",
    "VERSION": "1.0.0",
    "MAX_RETRIES": 3
  ])

  // Dynamic constants
  Constants {
    return [
      "deviceModel": UIDevice.current.model,
      "systemVersion": UIDevice.current.systemVersion
    ]
  }
}

Events

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

    // Define events
    Events("onDataReceived", "onError")

    // Function that sends events
    Function("startMonitoring") {
      startMonitor { data in
        // Send event to JavaScript
        self.sendEvent("onDataReceived", [
          "value": data,
          "timestamp": Date().timeIntervalSince1970
        ])
      }
    }
  }

  private func startMonitor(callback: @escaping (String) -> Void) {
    // Monitor implementation
  }
}

View Manager

ios/MyCustomView.swift
import ExpoModulesCore
import UIKit

public class MyCustomView: ExpoView {
  let label = UILabel()

  required init(appContext: AppContext? = nil) {
    super.init(appContext: appContext)
    addSubview(label)
    label.textAlignment = .center
  }

  public override func layoutSubviews() {
    super.layoutSubviews()
    label.frame = bounds
  }
}

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

    // Define view
    View(MyCustomView.self) {
      // Props
      Prop("text") { (view: MyCustomView, text: String) in
        view.label.text = text
      }

      Prop("color") { (view: MyCustomView, color: UIColor) in
        view.label.textColor = color
      }

      // Events
      Events("onPress")

      // Methods
      Function("setText") { (view: MyCustomView, text: String) in
        view.label.text = text
      }
    }
  }
}

Lifecycle Methods

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

    // Called when module is created
    OnCreate {
      print("Module created")
    }

    // Called when app enters foreground
    OnAppEntersForeground {
      print("App entered foreground")
    }

    // Called when app enters background
    OnAppEntersBackground {
      print("App entered background")
    }

    // Called when module is destroyed
    OnDestroy {
      print("Module destroyed")
    }
  }
}

Android Module (Kotlin)

Basic Module

android/src/main/java/expo/modules/mymodule/MyModule.kt
package expo.modules.mymodule

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class MyModule : Module() {
  override fun definition() = ModuleDefinition {
    // Module name
    Name("MyModule")

    // Synchronous function
    Function("hello") { name: String ->
      "Hello, $name!"
    }

    // Async function
    AsyncFunction("fetchData") { url: String ->
      val response = URL(url).readText()
      JSONObject(response)
    }

    // Function with promise
    AsyncFunction("compute") { value: Int ->
      // Long running operation
      withContext(Dispatchers.IO) {
        delay(1000)
        value * 2
      }
    }
  }
}

Constants

override fun definition() = ModuleDefinition {
  Name("MyModule")

  // Export constants
  Constants(
    "API_URL" to "https://api.example.com",
    "VERSION" to "1.0.0",
    "MAX_RETRIES" to 3
  )

  // Dynamic constants
  Constants {
    mapOf(
      "deviceModel" to Build.MODEL,
      "androidVersion" to Build.VERSION.SDK_INT
    )
  }
}

Events

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

    // Define events
    Events("onDataReceived", "onError")

    // Function that sends events
    Function("startMonitoring") {
      startMonitor { data ->
        // Send event to JavaScript
        sendEvent("onDataReceived", mapOf(
          "value" to data,
          "timestamp" to System.currentTimeMillis()
        ))
      }
    }
  }

  private fun startMonitor(callback: (String) -> Unit) {
    // Monitor implementation
  }
}

View Manager

android/src/main/java/expo/modules/mymodule/MyCustomView.kt
package expo.modules.mymodule

import android.content.Context
import android.widget.TextView
import expo.modules.kotlin.views.ExpoView

class MyCustomView(context: Context) : ExpoView(context) {
  private val textView = TextView(context).also {
    it.gravity = android.view.Gravity.CENTER
    addView(it)
  }

  fun setText(text: String) {
    textView.text = text
  }

  fun setColor(color: Int) {
    textView.setTextColor(color)
  }
}
android/src/main/java/expo/modules/mymodule/MyModule.kt
override fun definition() = ModuleDefinition {
  Name("MyModule")

  // Define view
  View(MyCustomView::class) {
    // Props
    Prop("text") { view: MyCustomView, text: String ->
      view.setText(text)
    }

    Prop("color") { view: MyCustomView, color: Int ->
      view.setColor(color)
    }

    // Events
    Events("onPress")

    // Methods
    Function("setText") { view: MyCustomView, text: String ->
      view.setText(text)
    }
  }
}

Lifecycle Methods

override fun definition() = ModuleDefinition {
  Name("MyModule")

  // Called when module is created
  OnCreate {
    Log.d("MyModule", "Module created")
  }

  // Called when activity is resumed
  OnActivityEntersForeground {
    Log.d("MyModule", "Activity entered foreground")
  }

  // Called when activity is paused
  OnActivityEntersBackground {
    Log.d("MyModule", "Activity entered background")
  }

  // Called when module is destroyed
  OnDestroy {
    Log.d("MyModule", "Module destroyed")
  }
}

JavaScript Interface

TypeScript Definitions

src/index.ts
import { requireNativeModule, EventEmitter } from 'expo-modules-core';

const MyModule = requireNativeModule('MyModule');

export interface MyModuleEvents {
  onDataReceived: (event: { value: string; timestamp: number }) => void;
  onError: (event: { message: string }) => void;
}

const emitter = new EventEmitter(MyModule);

export function hello(name: string): string {
  return MyModule.hello(name);
}

export async function fetchData(url: string): Promise<any> {
  return await MyModule.fetchData(url);
}

export function startMonitoring(): void {
  MyModule.startMonitoring();
}

export function addDataListener(
  listener: MyModuleEvents['onDataReceived']
): { remove: () => void } {
  return emitter.addListener('onDataReceived', listener);
}

// Constants
export const API_URL: string = MyModule.API_URL;
export const VERSION: string = MyModule.VERSION;

React Component

src/MyCustomView.tsx
import { requireNativeViewManager } from 'expo-modules-core';
import React from 'react';
import { ViewProps } from 'react-native';

const NativeView = requireNativeViewManager('MyModule');

export interface MyCustomViewProps extends ViewProps {
  text?: string;
  color?: string;
  onPress?: () => void;
}

export function MyCustomView(props: MyCustomViewProps) {
  return <NativeView {...props} />;
}

Type Conversions

Expo Modules API automatically converts types:
JavaScriptSwiftKotlin
stringStringString
numberInt, DoubleInt, Double
booleanBoolBoolean
Array[Any]List<Any>
Object[String: Any]Map<String, Any>
nullnilnull

Testing Native Modules

Swift Tests

ios/Tests/MyModuleTests.swift
import XCTest
@testable import MyModule

class MyModuleTests: XCTestCase {
  func testHello() {
    let module = MyModule()
    let result = module.hello(name: "World")
    XCTAssertEqual(result, "Hello, World!")
  }
}

Kotlin Tests

android/src/test/java/expo/modules/mymodule/MyModuleTest.kt
package expo.modules.mymodule

import org.junit.Test
import org.junit.Assert.*

class MyModuleTest {
  @Test
  fun testHello() {
    val module = MyModule()
    val result = module.hello("World")
    assertEquals("Hello, World!", result)
  }
}

Best Practices

1. Error Handling

AsyncFunction("riskyOperation") { () -> String in
  throw MyModuleException("Something went wrong")
}
AsyncFunction("riskyOperation") {
  throw ModuleException("Something went wrong")
}

2. Thread Safety

Function("doWork") {
  DispatchQueue.main.async {
    // UI work on main thread
  }
}
AsyncFunction("doWork") {
  withContext(Dispatchers.Main) {
    // UI work on main thread
  }
}

3. Memory Management

OnDestroy {
  // Clean up resources
  timer?.invalidate()
  observer?.removeObserver(self)
}

Next Steps

Prebuild

Generate native projects

Build Properties

Configure native builds

Debugging

Debug native code

Creating Builds

Build with native modules