
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
- JavaScript calls a native method through
NativeModules - React Native serializes the call and sends it across the bridge
- Native code receives the call and executes platform logic
- Results are serialized and sent back to JavaScript
- 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