Skip to main content
Technology & EngineeringReact Native305 lines

Native Modules Turbo Modules

Creating native modules and Turbo Modules to bridge platform-specific functionality into React Native

Quick Summary29 lines
You are an expert in native modules for building cross-platform mobile apps with React Native.

## Key Points

- Prefer the Expo Modules API over raw Turbo Modules — it handles both architectures, simplifies bridging, and provides a consistent API across platforms.
- Keep native modules focused and small. One module per concern (e.g., separate modules for biometrics, keychain, Bluetooth).
- Always provide TypeScript types for native module interfaces. Use codegen specs for Turbo Modules.
- Return promises for any native operation that might take time (file I/O, network, keychain access).
- Test native modules on real devices early. Simulators may not have all hardware capabilities.
- Use events (EventEmitter) for native-to-JS communication that is ongoing or unpredictable (sensor data, push notifications, download progress).
- **Threading issues**: Native module methods run on the native modules thread by default, not the main/UI thread. UI operations must be dispatched to the main thread explicitly.
- **Memory leaks from event listeners**: Always remove event subscriptions in cleanup functions. Leaked native listeners can keep the entire JS context in memory.
- **Type mismatches across the bridge**: The legacy bridge silently coerces types (numbers become strings). Turbo Modules with codegen catch these at build time — another reason to migrate.
- **Not handling the New Architecture flag**: Libraries must support both architectures during the transition. Check `newArchEnabled` in Gradle and use `#ifdef RCT_NEW_ARCH_ENABLED` in Objective-C++.
- **Blocking the JS thread with synchronous calls**: Even though Turbo Modules support sync calls, expensive operations should remain async to keep the UI responsive.
- **Forgetting `expo prebuild --clean`**: After changing native module code in a managed Expo project, you must regenerate native projects or changes will not appear.

## Quick Example

```bash
npx create-expo-module my-native-module
```

```ruby
# ios/Podfile additions (if not using Expo autolinking)
pod 'SomeNativeSDK', '~> 3.0'
```
skilldb get react-native-skills/Native Modules Turbo ModulesFull skill: 305 lines
Paste into your CLAUDE.md or agent config

Native Modules and Turbo Modules — React Native

You are an expert in native modules for building cross-platform mobile apps with React Native.

Core Philosophy

Native modules are the escape hatch when JavaScript cannot access a platform API directly. They exist to bridge the gap between React Native's JavaScript runtime and the native capabilities of iOS and Android. The key design principle is that the bridge should be as thin as possible: do the minimum work in native code, expose a clean asynchronous API to JavaScript, and keep business logic in the shared JavaScript layer. The less native code you write, the less platform-specific maintenance you carry.

The New Architecture (Turbo Modules and Fabric) represents a fundamental improvement over the legacy bridge. The old bridge serialized every call as JSON and sent it asynchronously across a message queue, adding latency and serialization overhead. Turbo Modules use JSI (JavaScript Interface) for direct, synchronous access to native objects in shared memory. This eliminates serialization, enables lazy module loading (modules are only initialized when first accessed, not at app startup), and provides type safety through codegen from TypeScript specs. Migrating to the New Architecture is not just a performance optimization -- it is the future of React Native.

The Expo Modules API is the practical recommendation for most native module development. It provides a unified API that works across both the legacy bridge and the New Architecture, generates boilerplate for both platforms, handles Swift/Kotlin interop cleanly, and integrates with Expo's autolinking system. Writing a native module with Expo Modules means you write approximately half the code compared to raw Turbo Modules, with the same runtime performance.

Anti-Patterns

  • Writing native modules for functionality that exists in Expo SDK: Before writing a custom native module for camera access, file system operations, secure storage, or push notifications, check if an Expo SDK module already provides it. Expo modules are maintained, tested against the current SDK version, and handle platform differences that you would otherwise need to discover and fix yourself.

  • Blocking the JavaScript thread with synchronous native calls: Even though Turbo Modules support synchronous function calls, using them for expensive operations (file I/O, crypto, image processing) blocks the JS thread and freezes the UI. Keep synchronous calls for fast, trivial operations (reading a cached config value) and use async for anything that takes measurable time.

  • Forgetting to clean up native event listeners: Native event subscriptions that are not removed when the JavaScript component unmounts keep the native listener active, holding references to the JS context and preventing garbage collection. Always return a cleanup function from useEffect that calls subscription.remove().

  • Not running expo prebuild --clean after changing native module code: In a managed Expo project, native projects are generated from configuration. Changes to native module source code, config plugins, or native dependencies require regeneration. Without --clean, the stale cached project masks your changes.

  • Passing unsupported types across the bridge without serialization: Custom classes, enums, Date objects, and complex nested types are not supported by the standard message codec. They must be serialized to supported primitives (strings, numbers, maps, lists) or, better, use Pigeon (Flutter) or codegen (Turbo Modules) for type-safe automatic serialization.

Overview

Native modules allow React Native apps to call platform-specific code (Swift/Kotlin/Java/Objective-C) from JavaScript. The legacy Bridge architecture serializes all calls as JSON over an asynchronous bridge. The New Architecture (Turbo Modules) uses JSI (JavaScript Interface) for synchronous, direct communication between JS and native code, eliminating serialization overhead. Expo Modules API provides a unified, simpler way to write native modules that work in both architectures.

Core Concepts

Architecture Comparison

FeatureLegacy BridgeTurbo Modules (New Architecture)
CommunicationAsync JSON bridgeSynchronous JSI
Type SafetyRuntime validationCodegen from spec
Lazy LoadingAll modules loaded at startupLoaded on first access
PerformanceSerialization overheadDirect memory access

Expo Modules API (Recommended)

The simplest way to write a native module for Expo and bare React Native projects:

npx create-expo-module my-native-module
modules/my-native-module/
  src/
    MyNativeModule.ts          # TypeScript wrapper
    MyNativeModule.web.ts      # Web implementation (optional)
  ios/
    MyNativeModule.swift       # iOS implementation
  android/
    src/main/java/.../
      MyNativeModule.kt        # Android implementation
  expo-module.config.json

iOS Implementation (Swift via Expo Modules)

// ios/MyNativeModule.swift
import ExpoModulesCore

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

    // Synchronous function
    Function("getDeviceModel") { () -> String in
      return UIDevice.current.model
    }

    // Async function
    AsyncFunction("fetchSecureToken") { (key: String, promise: Promise) in
      guard let token = KeychainHelper.get(key: key) else {
        promise.reject("NOT_FOUND", "Token not found for key: \(key)")
        return
      }
      promise.resolve(token)
    }

    // Events
    Events("onBatteryChange")

    // View (native UI component)
    View(MyNativeView.self) {
      Prop("cornerRadius") { (view: MyNativeView, radius: Double) in
        view.layer.cornerRadius = radius
      }

      Events("onTap")
    }
  }
}

Android Implementation (Kotlin via Expo Modules)

// android/src/main/java/com/example/MyNativeModule.kt
package com.example.mynativemodule

import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import android.os.Build

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

    Function("getDeviceModel") {
      Build.MODEL
    }

    AsyncFunction("fetchSecureToken") { key: String ->
      val token = KeystoreHelper.get(key)
        ?: throw Exception("Token not found for key: $key")
      token
    }

    Events("onBatteryChange")
  }
}

TypeScript Wrapper

// src/MyNativeModule.ts
import { requireNativeModule } from "expo-modules-core";

interface MyNativeModuleInterface {
  getDeviceModel(): string;
  fetchSecureToken(key: string): Promise<string>;
  addListener(eventName: string): void;
  removeListeners(count: number): void;
}

const NativeModule =
  requireNativeModule<MyNativeModuleInterface>("MyNativeModule");

export function getDeviceModel(): string {
  return NativeModule.getDeviceModel();
}

export async function fetchSecureToken(key: string): Promise<string> {
  return NativeModule.fetchSecureToken(key);
}

export { NativeModule };

Implementation Patterns

Turbo Module with Codegen (New Architecture)

For projects using the New Architecture directly (without Expo Modules):

// specs/NativeCalculator.ts
import type { TurboModule } from "react-native";
import { TurboModuleRegistry } from "react-native";

export interface Spec extends TurboModule {
  add(a: number, b: number): number; // Synchronous
  multiply(a: number, b: number): Promise<number>; // Async
  getConstants(): {
    PI: number;
    E: number;
  };
}

export default TurboModuleRegistry.getEnforcing<Spec>("NativeCalculator");
// Android implementation
package com.example

import com.facebook.react.bridge.ReactApplicationContext
import com.example.NativeCalculatorSpec

class NativeCalculatorModule(context: ReactApplicationContext) :
  NativeCalculatorSpec(context) {

  override fun getName() = "NativeCalculator"

  override fun add(a: Double, b: Double): Double = a + b

  override fun multiply(a: Double, b: Double, promise: Promise) {
    promise.resolve(a * b)
  }

  override fun getTypedExportedConstants(): Map<String, Any> = mapOf(
    "PI" to Math.PI,
    "E" to Math.E,
  )
}

Fabric Native Component (New Architecture UI)

// specs/NativeMapView.ts
import type { ViewProps } from "react-native";
import type {
  Float,
  DirectEventHandler,
} from "react-native/Libraries/Types/CodegenTypes";
import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent";

interface NativeMapViewProps extends ViewProps {
  latitude: Float;
  longitude: Float;
  zoomLevel: Float;
  onRegionChange?: DirectEventHandler<{
    latitude: Float;
    longitude: Float;
  }>;
}

export default codegenNativeComponent<NativeMapViewProps>("NativeMapView");

Native Event Emitters

// Using Expo Modules events
import { EventEmitter, Subscription } from "expo-modules-core";
import { NativeModule } from "./MyNativeModule";

const emitter = new EventEmitter(NativeModule);

export function onBatteryChange(
  callback: (event: { level: number; isCharging: boolean }) => void
): Subscription {
  return emitter.addListener("onBatteryChange", callback);
}

// Usage in component
function BatteryIndicator() {
  const [battery, setBattery] = useState({ level: 1, isCharging: false });

  useEffect(() => {
    const subscription = onBatteryChange(setBattery);
    return () => subscription.remove();
  }, []);

  return <Text>Battery: {Math.round(battery.level * 100)}%</Text>;
}

Linking Existing Native Libraries

// expo-module.config.json
{
  "platforms": ["ios", "android"],
  "ios": {
    "modules": ["MyNativeModule"]
  },
  "android": {
    "modules": ["com.example.mynativemodule.MyNativeModule"]
  }
}
# ios/Podfile additions (if not using Expo autolinking)
pod 'SomeNativeSDK', '~> 3.0'
// android/build.gradle
dependencies {
    implementation 'com.some.native:sdk:3.0.0'
}

Best Practices

  • Prefer the Expo Modules API over raw Turbo Modules — it handles both architectures, simplifies bridging, and provides a consistent API across platforms.
  • Keep native modules focused and small. One module per concern (e.g., separate modules for biometrics, keychain, Bluetooth).
  • Always provide TypeScript types for native module interfaces. Use codegen specs for Turbo Modules.
  • Return promises for any native operation that might take time (file I/O, network, keychain access).
  • Test native modules on real devices early. Simulators may not have all hardware capabilities.
  • Use events (EventEmitter) for native-to-JS communication that is ongoing or unpredictable (sensor data, push notifications, download progress).

Common Pitfalls

  • Threading issues: Native module methods run on the native modules thread by default, not the main/UI thread. UI operations must be dispatched to the main thread explicitly.
  • Memory leaks from event listeners: Always remove event subscriptions in cleanup functions. Leaked native listeners can keep the entire JS context in memory.
  • Type mismatches across the bridge: The legacy bridge silently coerces types (numbers become strings). Turbo Modules with codegen catch these at build time — another reason to migrate.
  • Not handling the New Architecture flag: Libraries must support both architectures during the transition. Check newArchEnabled in Gradle and use #ifdef RCT_NEW_ARCH_ENABLED in Objective-C++.
  • Blocking the JS thread with synchronous calls: Even though Turbo Modules support sync calls, expensive operations should remain async to keep the UI responsive.
  • Forgetting expo prebuild --clean: After changing native module code in a managed Expo project, you must regenerate native projects or changes will not appear.

Install this skill directly: skilldb add react-native-skills

Get CLI access →