React Native

Creating Custom Native Modules for React Native (iOS & Android)

Introduction

While React Native covers many common use cases through its core APIs and extensive library ecosystem, some applications require direct access to platform-specific features that no existing package provides. In such cases, custom native modules allow you to extend React Native with iOS and Android code, bridging native functionality directly into JavaScript. This capability unlocks access to proprietary SDKs, hardware sensors, performance-critical operations, and any platform API that React Native does not expose by default. In this comprehensive guide, you will learn when native modules are necessary, understand how the bridge architecture works, and build complete native modules for both iOS and Android with proper error handling, event emission, and TypeScript integration.

Why Create Custom Native Modules

React Native provides a rich ecosystem of community libraries. However, certain requirements go beyond what existing packages offer.

Common Use Cases

  • Access unsupported native APIs: Device sensors, system settings, or OS features not exposed by React Native
  • Integrate proprietary SDKs: Analytics platforms, payment processors, or enterprise tools
  • Improve performance: Heavy computation, image processing, or cryptographic operations
  • Reuse existing native code: Port legacy iOS or Android libraries to React Native
  • Implement platform-specific behavior: Features that work fundamentally differently on each platform

How Native Modules Work in React Native

Native modules act as a bridge between JavaScript and platform code. React Native’s architecture separates the JavaScript runtime from native code, with communication happening across the bridge.

Bridge Architecture

  1. JavaScript calls a native method through NativeModules
  2. React Native serializes the call and sends it across the bridge
  3. Native code receives the call and executes platform logic
  4. Results are serialized and sent back to JavaScript
  5. JavaScript receives the result through a Promise or callback

New Architecture: Turbo Modules

React Native’s new architecture introduces Turbo Modules, which provide faster communication through direct JavaScript Interface (JSI) bindings. While this guide covers the classic bridge approach, the concepts translate to Turbo Modules with syntax changes.

Creating a Native Module on Android

Android native modules can be written in Java or Kotlin. Modern projects typically use Kotlin.

Project Structure

android/app/src/main/java/com/yourapp/
├── MainApplication.kt
├── MainActivity.kt
└── modules/
    ├── DeviceModule.kt
    └── DevicePackage.kt

Native Module Class (Kotlin)

// DeviceModule.kt
package com.yourapp.modules

import android.os.Build
import android.provider.Settings
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule

class DeviceModule(private val reactContext: ReactApplicationContext) :
    ReactContextBaseJavaModule(reactContext) {

    override fun getName(): String = "DeviceModule"

    // Expose constants to JavaScript
    override fun getConstants(): Map<String, Any> {
        return mapOf(
            "BRAND" to Build.BRAND,
            "MODEL" to Build.MODEL,
            "OS_VERSION" to Build.VERSION.RELEASE
        )
    }

    // Simple synchronous method
    @ReactMethod(isBlockingSynchronousMethod = true)
    fun getDeviceIdSync(): String {
        return Settings.Secure.getString(
            reactContext.contentResolver,
            Settings.Secure.ANDROID_ID
        )
    }

    // Async method with Promise
    @ReactMethod
    fun getDeviceInfo(promise: Promise) {
        try {
            val info = Arguments.createMap().apply {
                putString("brand", Build.BRAND)
                putString("model", Build.MODEL)
                putString("osVersion", Build.VERSION.RELEASE)
                putInt("sdkVersion", Build.VERSION.SDK_INT)
                putString("deviceId", getDeviceIdSync())
            }
            promise.resolve(info)
        } catch (e: Exception) {
            promise.reject("DEVICE_ERROR", "Failed to get device info", e)
        }
    }

    // Method with callback
    @ReactMethod
    fun getBatteryLevel(callback: Callback) {
        val batteryManager = reactContext.getSystemService(
            android.content.Context.BATTERY_SERVICE
        ) as android.os.BatteryManager
        
        val level = batteryManager.getIntProperty(
            android.os.BatteryManager.BATTERY_PROPERTY_CAPACITY
        )
        callback.invoke(null, level)
    }

    // Method with parameters
    @ReactMethod
    fun vibrate(duration: Int, promise: Promise) {
        try {
            val vibrator = reactContext.getSystemService(
                android.content.Context.VIBRATOR_SERVICE
            ) as android.os.Vibrator
            
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                vibrator.vibrate(
                    android.os.VibrationEffect.createOneShot(
                        duration.toLong(),
                        android.os.VibrationEffect.DEFAULT_AMPLITUDE
                    )
                )
            } else {
                @Suppress("DEPRECATION")
                vibrator.vibrate(duration.toLong())
            }
            promise.resolve(true)
        } catch (e: Exception) {
            promise.reject("VIBRATE_ERROR", e.message, e)
        }
    }

    // Send events to JavaScript
    private fun sendEvent(eventName: String, params: WritableMap?) {
        reactContext
            .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
            .emit(eventName, params)
    }

    @ReactMethod
    fun startMonitoring() {
        // Example: emit events periodically
        val params = Arguments.createMap().apply {
            putString("status", "monitoring_started")
        }
        sendEvent("DeviceEvent", params)
    }

    // Required for event emission
    @ReactMethod
    fun addListener(eventName: String) {
        // Keep track of listeners if needed
    }

    @ReactMethod
    fun removeListeners(count: Int) {
        // Clean up listeners if needed
    }
}

Package Registration

// DevicePackage.kt
package com.yourapp.modules

import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager

class DevicePackage : ReactPackage {
    override fun createNativeModules(
        reactContext: ReactApplicationContext
    ): List<NativeModule> {
        return listOf(DeviceModule(reactContext))
    }

    override fun createViewManagers(
        reactContext: ReactApplicationContext
    ): List<ViewManager<*, *>> {
        return emptyList()
    }
}

Register in MainApplication

// MainApplication.kt
override fun getPackages(): List<ReactPackage> =
    PackageList(this).packages.apply {
        add(DevicePackage()) // Add your custom package
    }

Creating a Native Module on iOS

iOS native modules can be written in Swift or Objective-C. Swift is recommended for new code, but requires a bridging header.

Project Structure

ios/YourApp/
├── AppDelegate.swift
├── YourApp-Bridging-Header.h
└── Modules/
    ├── DeviceModule.swift
    └── DeviceModule.m

Bridging Header

// YourApp-Bridging-Header.h
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>

Swift Module Implementation

// DeviceModule.swift
import Foundation
import UIKit

@objc(DeviceModule)
class DeviceModule: RCTEventEmitter {
    
    private var hasListeners = false
    
    // Module name for JavaScript
    @objc override static func moduleName() -> String! {
        return "DeviceModule"
    }
    
    // Constants exposed to JavaScript
    @objc override func constantsToExport() -> [AnyHashable: Any]! {
        return [
            "BRAND": "Apple",
            "MODEL": UIDevice.current.model,
            "OS_VERSION": UIDevice.current.systemVersion
        ]
    }
    
    // Required: Run on main queue for UI access
    @objc override static func requiresMainQueueSetup() -> Bool {
        return true
    }
    
    // Supported events for emission
    @objc override func supportedEvents() -> [String]! {
        return ["DeviceEvent"]
    }
    
    // Track listener status
    @objc override func startObserving() {
        hasListeners = true
    }
    
    @objc override func stopObserving() {
        hasListeners = false
    }
    
    // Async method with Promise
    @objc func getDeviceInfo(
        _ resolve: @escaping RCTPromiseResolveBlock,
        rejecter reject: @escaping RCTPromiseRejectBlock
    ) {
        let device = UIDevice.current
        
        let info: [String: Any] = [
            "brand": "Apple",
            "model": device.model,
            "osVersion": device.systemVersion,
            "name": device.name,
            "identifierForVendor": device.identifierForVendor?.uuidString ?? ""
        ]
        
        resolve(info)
    }
    
    // Method with parameters
    @objc func vibrate(
        _ duration: Int,
        resolver resolve: @escaping RCTPromiseResolveBlock,
        rejecter reject: @escaping RCTPromiseRejectBlock
    ) {
        // iOS doesn't support custom vibration duration
        // Using haptic feedback instead
        DispatchQueue.main.async {
            let generator = UIImpactFeedbackGenerator(style: .medium)
            generator.impactOccurred()
            resolve(true)
        }
    }
    
    // Get battery level
    @objc func getBatteryLevel(
        _ resolve: @escaping RCTPromiseResolveBlock,
        rejecter reject: @escaping RCTPromiseRejectBlock
    ) {
        DispatchQueue.main.async {
            UIDevice.current.isBatteryMonitoringEnabled = true
            let level = UIDevice.current.batteryLevel
            
            if level < 0 {
                reject("BATTERY_ERROR", "Battery level unavailable", nil)
            } else {
                resolve(Int(level * 100))
            }
        }
    }
    
    // Send events to JavaScript
    @objc func startMonitoring() {
        if hasListeners {
            sendEvent(withName: "DeviceEvent", body: [
                "status": "monitoring_started"
            ])
        }
    }
}

Objective-C Bridge File

// DeviceModule.m
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>

@interface RCT_EXTERN_MODULE(DeviceModule, RCTEventEmitter)

RCT_EXTERN_METHOD(
  getDeviceInfo:
  (RCTPromiseResolveBlock)resolve
  rejecter:(RCTPromiseRejectBlock)reject
)

RCT_EXTERN_METHOD(
  vibrate:(int)duration
  resolver:(RCTPromiseResolveBlock)resolve
  rejecter:(RCTPromiseRejectBlock)reject
)

RCT_EXTERN_METHOD(
  getBatteryLevel:
  (RCTPromiseResolveBlock)resolve
  rejecter:(RCTPromiseRejectBlock)reject
)

RCT_EXTERN_METHOD(startMonitoring)

@end

Using the Native Module in JavaScript

Basic Usage

import { NativeModules, NativeEventEmitter, Platform } from 'react-native';

const { DeviceModule } = NativeModules;

// Access constants
console.log('Brand:', DeviceModule.BRAND);
console.log('Model:', DeviceModule.MODEL);

// Call async method
async function getDeviceInfo() {
  try {
    const info = await DeviceModule.getDeviceInfo();
    console.log('Device info:', info);
    return info;
  } catch (error) {
    console.error('Failed to get device info:', error);
    throw error;
  }
}

// Call method with parameters
async function triggerVibration() {
  try {
    await DeviceModule.vibrate(100);
  } catch (error) {
    console.error('Vibration failed:', error);
  }
}

TypeScript Wrapper Module

// src/native/DeviceModule.ts
import { NativeModules, NativeEventEmitter, Platform } from 'react-native';

interface DeviceInfo {
  brand: string;
  model: string;
  osVersion: string;
  deviceId?: string;
  name?: string;
}

interface DeviceModuleType {
  BRAND: string;
  MODEL: string;
  OS_VERSION: string;
  getDeviceInfo(): Promise<DeviceInfo>;
  getBatteryLevel(): Promise<number>;
  vibrate(duration: number): Promise<boolean>;
  startMonitoring(): void;
}

const { DeviceModule } = NativeModules as { DeviceModule: DeviceModuleType };

const deviceEventEmitter = new NativeEventEmitter(DeviceModule as any);

export const Device = {
  // Constants
  brand: DeviceModule.BRAND,
  model: DeviceModule.MODEL,
  osVersion: DeviceModule.OS_VERSION,
  
  // Methods
  async getInfo(): Promise<DeviceInfo> {
    return DeviceModule.getDeviceInfo();
  },
  
  async getBatteryLevel(): Promise<number> {
    return DeviceModule.getBatteryLevel();
  },
  
  async vibrate(duration: number = 100): Promise<void> {
    await DeviceModule.vibrate(duration);
  },
  
  // Event subscription
  onDeviceEvent(callback: (event: any) => void) {
    const subscription = deviceEventEmitter.addListener('DeviceEvent', callback);
    DeviceModule.startMonitoring();
    return () => subscription.remove();
  },
};

export default Device;

Using the TypeScript Wrapper

import Device from './native/DeviceModule';
import { useEffect, useState } from 'react';

function useDeviceInfo() {
  const [deviceInfo, setDeviceInfo] = useState<DeviceInfo | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    Device.getInfo()
      .then(setDeviceInfo)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);

  return { deviceInfo, loading, error };
}

function DeviceScreen() {
  const { deviceInfo, loading, error } = useDeviceInfo();

  useEffect(() => {
    // Subscribe to device events
    const unsubscribe = Device.onDeviceEvent((event) => {
      console.log('Device event:', event);
    });

    return unsubscribe;
  }, []);

  if (loading) return <Text>Loading...</Text>;
  if (error) return <Text>Error: {error.message}</Text>;

  return (
    <View>
      <Text>Brand: {deviceInfo?.brand}</Text>
      <Text>Model: {deviceInfo?.model}</Text>
      <Button title="Vibrate" onPress={() => Device.vibrate(200)} />
    </View>
  );
}

Common Mistakes to Avoid

Overusing Native Modules

Many problems can be solved in JavaScript alone. Check if a community library exists before writing native code.

Ignoring Bridge Overhead

Each bridge call has serialization overhead. Batch operations together rather than making many small calls.

Poor Error Handling

Always reject promises with meaningful error codes and messages. Never let exceptions propagate silently.

Blocking the Main Thread

Long-running operations should use background threads. Blocking the main thread causes UI freezes.

Inconsistent Cross-Platform APIs

Keep method signatures and return types consistent between iOS and Android to simplify JavaScript usage.

Forgetting Event Listener Cleanup

Always implement addListener and removeListeners methods and clean up subscriptions in JavaScript.

Testing Native Modules

Native modules require thorough testing across platforms and OS versions:

  • Test on real devices, not just simulators
  • Verify error handling paths
  • Test on multiple OS versions
  • Validate memory management and cleanup
  • Check performance under load

Conclusion

Custom native modules allow React Native apps to reach beyond JavaScript limitations while maintaining a shared codebase. By carefully bridging iOS and Android functionality with proper error handling, event emission, and TypeScript types, you can integrate powerful native features without sacrificing maintainability. The key is knowing when native modules are truly necessary and implementing them with consistent, well-documented APIs. If you are deciding on your React Native setup, read Expo vs React Native CLI: Deciding Which to Use. For offline-ready architectures, see Building Offline-Ready React Native Apps with Redux Persist. For official documentation, visit the React Native Native Modules guide. With the right approach, native modules unlock the full power of React Native development.

1 Comment

Leave a Comment