Building Custom Native Modules for React Native
Building Custom Native Modules for React Native
React Native's strength lies in its ability to bridge JavaScript and native platform code, allowing developers to access platform-specific APIs and functionality. While React Native provides many built-in modules, there are times when you need to create custom native modules to:
- Access platform-specific APIs not covered by React Native
- Integrate third-party native libraries
- Optimize performance-critical operations
- Implement custom UI components with native behavior
In this comprehensive guide, we'll explore how to build custom native modules for both iOS and Android platforms.
Understanding Native Modules
Native modules are JavaScript classes that expose native platform functionality to your React Native application. They act as a bridge between your JavaScript code and the underlying native platform APIs.
Key Concepts
- Bridge Communication: Native modules communicate with JavaScript through React Native's bridge
- Asynchronous by Default: Most native module methods are asynchronous to avoid blocking the JavaScript thread
- Platform-Specific Implementation: Each platform (iOS/Android) requires its own implementation
Setting Up the Development Environment
Before creating custom native modules, ensure you have the necessary development tools:
For iOS Development
# Install Xcode from the App Store
# Install CocoaPods
sudo gem install cocoapods
For Android Development
# Install Android Studio
# Set up Android SDK and build tools
# Configure environment variables (ANDROID_HOME, etc.)
Creating Your First Native Module
Let's create a simple native module that provides device information. We'll call it DeviceInfoModule
.
Step 1: Create the JavaScript Interface
First, create the JavaScript interface that will be used by your React Native app:
// DeviceInfoModule.js
import { NativeModules } from "react-native";
const { DeviceInfoModule } = NativeModules;
export default {
getDeviceId: () => {
return DeviceInfoModule.getDeviceId();
},
getBatteryLevel: () => {
return DeviceInfoModule.getBatteryLevel();
},
getSystemVersion: () => {
return DeviceInfoModule.getSystemVersion();
},
};
Step 2: Implement the iOS Native Module
Create the iOS implementation using Objective-C or Swift:
Objective-C Implementation
Create DeviceInfoModule.h
:
// DeviceInfoModule.h
#import <React/RCTBridgeModule.h>
@interface DeviceInfoModule : NSObject <RCTBridgeModule>
@end
Create DeviceInfoModule.m
:
// DeviceInfoModule.m
#import "DeviceInfoModule.h"
#import <React/RCTLog.h>
#import <UIKit/UIKit.h>
@implementation DeviceInfoModule
// Export module name to JavaScript
RCT_EXPORT_MODULE();
// Export method to get device ID
RCT_EXPORT_METHOD(getDeviceId:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
NSString *deviceId = [[[UIDevice currentDevice] identifierForVendor] UUIDString];
if (deviceId) {
resolve(deviceId);
} else {
reject(@"device_id_error", @"Could not retrieve device ID", nil);
}
}
// Export method to get battery level
RCT_EXPORT_METHOD(getBatteryLevel:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
[UIDevice currentDevice].batteryMonitoringEnabled = YES;
float batteryLevel = [UIDevice currentDevice].batteryLevel;
if (batteryLevel >= 0) {
resolve(@(batteryLevel * 100)); // Convert to percentage
} else {
reject(@"battery_error", @"Could not retrieve battery level", nil);
}
}
// Export method to get system version
RCT_EXPORT_METHOD(getSystemVersion:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
NSString *systemVersion = [[UIDevice currentDevice] systemVersion];
resolve(systemVersion);
}
@end
Swift Implementation (Alternative)
If you prefer Swift, create a bridging header and implement in Swift:
// DeviceInfoModule.swift
import Foundation
import UIKit
@objc(DeviceInfoModule)
class DeviceInfoModule: NSObject {
@objc
func getDeviceId(_ resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
if let deviceId = UIDevice.current.identifierForVendor?.uuidString {
resolve(deviceId)
} else {
reject("device_id_error", "Could not retrieve device ID", nil)
}
}
@objc
func getBatteryLevel(_ resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
UIDevice.current.isBatteryMonitoringEnabled = true
let batteryLevel = UIDevice.current.batteryLevel
if batteryLevel >= 0 {
resolve(batteryLevel * 100) // Convert to percentage
} else {
reject("battery_error", "Could not retrieve battery level", nil)
}
}
@objc
func getSystemVersion(_ resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
resolve(UIDevice.current.systemVersion)
}
@objc
static func requiresMainQueueSetup() -> Bool {
return false
}
}
Don't forget the bridging header (DeviceInfoModule-Bridging-Header.h
):
#import <React/RCTBridgeModule.h>
Step 3: Implement the Android Native Module
Create the Android implementation using Java or Kotlin:
Java Implementation
Create DeviceInfoModule.java
:
// DeviceInfoModule.java
package com.yourapp.modules;
import android.content.Context;
import android.content.IntentFilter;
import android.content.Intent;
import android.os.BatteryManager;
import android.os.Build;
import android.provider.Settings;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;
public class DeviceInfoModule extends ReactContextBaseJavaModule {
private static ReactApplicationContext reactContext;
DeviceInfoModule(ReactApplicationContext context) {
super(context);
reactContext = context;
}
@Override
public String getName() {
return "DeviceInfoModule";
}
@ReactMethod
public void getDeviceId(Promise promise) {
try {
String deviceId = Settings.Secure.getString(
reactContext.getContentResolver(),
Settings.Secure.ANDROID_ID
);
promise.resolve(deviceId);
} catch (Exception e) {
promise.reject("device_id_error", "Could not retrieve device ID", e);
}
}
@ReactMethod
public void getBatteryLevel(Promise promise) {
try {
IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
Intent batteryStatus = reactContext.registerReceiver(null, ifilter);
int level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
float batteryPct = level * 100 / (float) scale;
promise.resolve(batteryPct);
} catch (Exception e) {
promise.reject("battery_error", "Could not retrieve battery level", e);
}
}
@ReactMethod
public void getSystemVersion(Promise promise) {
try {
promise.resolve(Build.VERSION.RELEASE);
} catch (Exception e) {
promise.reject("system_version_error", "Could not retrieve system version", e);
}
}
}
Create the package class DeviceInfoPackage.java
:
// DeviceInfoPackage.java
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;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class DeviceInfoPackage implements ReactPackage {
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new DeviceInfoModule(reactContext));
return modules;
}
}
Kotlin Implementation (Alternative)
// DeviceInfoModule.kt
package com.yourapp.modules
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build
import android.provider.Settings
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise
class DeviceInfoModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName(): String {
return "DeviceInfoModule"
}
@ReactMethod
fun getDeviceId(promise: Promise) {
try {
val deviceId = Settings.Secure.getString(
reactApplicationContext.contentResolver,
Settings.Secure.ANDROID_ID
)
promise.resolve(deviceId)
} catch (e: Exception) {
promise.reject("device_id_error", "Could not retrieve device ID", e)
}
}
@ReactMethod
fun getBatteryLevel(promise: Promise) {
try {
val intentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
val batteryStatus = reactApplicationContext.registerReceiver(null, intentFilter)
val level = batteryStatus?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = batteryStatus?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
val batteryPercent = level * 100 / scale.toFloat()
promise.resolve(batteryPercent)
} catch (e: Exception) {
promise.reject("battery_error", "Could not retrieve battery level", e)
}
}
@ReactMethod
fun getSystemVersion(promise: Promise) {
try {
promise.resolve(Build.VERSION.RELEASE)
} catch (e: Exception) {
promise.reject("system_version_error", "Could not retrieve system version", e)
}
}
}
Step 4: Register the Module
For Android
In your MainApplication.java
, add the package:
// MainApplication.java
import com.yourapp.modules.DeviceInfoPackage;
@Override
protected List<ReactPackage> getPackages() {
@SuppressWarnings("UnnecessaryLocalVariable")
List<ReactPackage> packages = new PackageList(this).getPackages();
packages.add(new DeviceInfoPackage()); // Add this line
return packages;
}
For iOS
No additional registration is needed for iOS. The module will be automatically discovered.
Step 5: Use the Module in Your React Native App
// App.js
import React, { useEffect, useState } from "react";
import { View, Text, Button, Alert } from "react-native";
import DeviceInfoModule from "./DeviceInfoModule";
const App = () => {
const [deviceInfo, setDeviceInfo] = useState({});
const getDeviceInfo = async () => {
try {
const [deviceId, batteryLevel, systemVersion] = await Promise.all([
DeviceInfoModule.getDeviceId(),
DeviceInfoModule.getBatteryLevel(),
DeviceInfoModule.getSystemVersion(),
]);
setDeviceInfo({
deviceId,
batteryLevel: `${batteryLevel.toFixed(1)}%`,
systemVersion,
});
} catch (error) {
Alert.alert("Error", error.message);
}
};
return (
<View style={{ flex: 1, justifyContent: "center", padding: 20 }}>
<Button title="Get Device Info" onPress={getDeviceInfo} />
{Object.keys(deviceInfo).length > 0 && (
<View style={{ marginTop: 20 }}>
<Text>Device ID: {deviceInfo.deviceId}</Text>
<Text>Battery Level: {deviceInfo.batteryLevel}</Text>
<Text>System Version: {deviceInfo.systemVersion}</Text>
</View>
)}
</View>
);
};
export default App;
Advanced Features
Event Emitters
For continuous data streams or events, you can use event emitters:
iOS Implementation
// DeviceInfoModule.m
#import <React/RCTEventEmitter.h>
@interface DeviceInfoModule : RCTEventEmitter <RCTBridgeModule>
@end
@implementation DeviceInfoModule
RCT_EXPORT_MODULE();
- (NSArray<NSString *> *)supportedEvents {
return @[@"BatteryLevelChanged"];
}
- (void)startObserving {
[[NSNotificationCenter defaultCenter]
addObserver:self
selector:@selector(batteryLevelChanged:)
name:UIDeviceBatteryLevelDidChangeNotification
object:nil];
[UIDevice currentDevice].batteryMonitoringEnabled = YES;
}
- (void)stopObserving {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (void)batteryLevelChanged:(NSNotification *)notification {
float batteryLevel = [UIDevice currentDevice].batteryLevel;
[self sendEventWithName:@"BatteryLevelChanged"
body:@{@"level": @(batteryLevel * 100)}];
}
@end
Android Implementation
// DeviceInfoModule.java
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.bridge.Arguments;
public class DeviceInfoModule extends ReactContextBaseJavaModule {
private void sendEvent(ReactApplicationContext reactContext,
String eventName,
WritableMap params) {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
.emit(eventName, params);
}
@ReactMethod
public void startBatteryMonitoring() {
// Register broadcast receiver for battery changes
IntentFilter filter = new IntentFilter();
filter.addAction(Intent.ACTION_BATTERY_CHANGED);
BroadcastReceiver batteryReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
int level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
int scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
float batteryPct = level * 100 / (float) scale;
WritableMap params = Arguments.createMap();
params.putDouble("level", batteryPct);
sendEvent(reactContext, "BatteryLevelChanged", params);
}
};
reactContext.registerReceiver(batteryReceiver, filter);
}
}
Using Events in JavaScript
// App.js
import { DeviceEventEmitter } from "react-native";
useEffect(() => {
const subscription = DeviceEventEmitter.addListener(
"BatteryLevelChanged",
(data) => {
console.log("Battery level:", data.level);
}
);
return () => subscription.remove();
}, []);
Synchronous Methods
For simple operations that don't require async processing:
iOS
RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getDeviceName)
{
return [[UIDevice currentDevice] name];
}
Android
@ReactMethod(isBlockingSynchronousMethod = true)
public String getDeviceName() {
return Build.MODEL;
}
Best Practices
1. Error Handling
Always implement proper error handling with descriptive error messages:
// Good error handling
@ReactMethod
public void riskyOperation(Promise promise) {
try {
// Perform operation
String result = performOperation();
promise.resolve(result);
} catch (SecurityException e) {
promise.reject("SECURITY_ERROR", "Permission denied", e);
} catch (Exception e) {
promise.reject("UNKNOWN_ERROR", "An unexpected error occurred", e);
}
}
2. Threading Considerations
iOS
// Run on main thread for UI operations
RCT_EXPORT_METHOD(updateUI:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
dispatch_async(dispatch_get_main_queue(), ^{
// UI operations here
resolve(@"Updated");
});
}
// Run on background thread for heavy operations
RCT_EXPORT_METHOD(heavyOperation:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
// Heavy computation here
dispatch_async(dispatch_get_main_queue(), ^{
resolve(@"Completed");
});
});
}
Android
@ReactMethod
public void heavyOperation(Promise promise) {
AsyncTask.execute(() -> {
try {
// Heavy computation on background thread
String result = performHeavyComputation();
// Resolve on main thread
new Handler(Looper.getMainLooper()).post(() -> {
promise.resolve(result);
});
} catch (Exception e) {
promise.reject("COMPUTATION_ERROR", e.getMessage(), e);
}
});
}
3. Memory Management
iOS
- Use
weak
references to avoid retain cycles - Clean up observers and timers in
dealloc
Android
- Remove listeners and callbacks
- Use weak references where appropriate
- Clean up in module lifecycle methods
4. Testing Native Modules
Create unit tests for your native modules:
iOS Testing
// DeviceInfoModuleTests.m
#import <XCTest/XCTest.h>
#import "DeviceInfoModule.h"
@interface DeviceInfoModuleTests : XCTestCase
@property (nonatomic, strong) DeviceInfoModule *module;
@end
@implementation DeviceInfoModuleTests
- (void)setUp {
self.module = [[DeviceInfoModule alloc] init];
}
- (void)testGetSystemVersion {
XCTestExpectation *expectation = [self expectationWithDescription:@"Get system version"];
[self.module getSystemVersion:^(id result) {
XCTAssertNotNil(result);
XCTAssertTrue([result isKindOfClass:[NSString class]]);
[expectation fulfill];
} rejecter:^(NSString *code, NSString *message, NSError *error) {
XCTFail(@"Should not reject");
}];
[self waitForExpectationsWithTimeout:5.0 handler:nil];
}
@end
Android Testing
// DeviceInfoModuleTest.java
@RunWith(RobolectricTestRunner.class)
public class DeviceInfoModuleTest {
private DeviceInfoModule module;
@Before
public void setUp() {
ReactApplicationContext context = mock(ReactApplicationContext.class);
module = new DeviceInfoModule(context);
}
@Test
public void testGetSystemVersion() {
Promise promise = mock(Promise.class);
module.getSystemVersion(promise);
verify(promise).resolve(anyString());
}
}
Common Pitfalls and Solutions
1. Bridge Communication Overhead
- Minimize data passing between native and JavaScript
- Batch operations when possible
- Use native UI components for performance-critical interfaces
2. Platform Differences
- Abstract platform differences in your JavaScript interface
- Use feature detection rather than platform detection
- Provide graceful fallbacks for unsupported features
3. Permissions
- Always check and request permissions before accessing sensitive APIs
- Provide clear error messages for permission denials
- Handle permission state changes gracefully
// Example: Handling permissions
const requestLocationPermission = async () => {
try {
if (Platform.OS === "android") {
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION
);
return granted === PermissionsAndroid.RESULTS.GRANTED;
}
return true; // iOS handles permissions differently
} catch (error) {
console.warn("Permission request failed:", error);
return false;
}
};
Conclusion
Building custom native modules for React Native opens up endless possibilities for integrating platform-specific functionality into your applications. By following the patterns and best practices outlined in this guide, you can create robust, performant native modules that seamlessly bridge the gap between JavaScript and native platform APIs.
Remember to:
- Start with a clear JavaScript interface
- Implement platform-specific code following native conventions
- Handle errors gracefully
- Consider threading and performance implications
- Test thoroughly on both platforms
- Document your modules well for future maintenance
With these foundations, you'll be well-equipped to extend React Native's capabilities and create truly native experiences for your users.