Developing a Native Module for React Native Applications (Android)
A Native Module is needed when JavaScript cannot reach the required Android API: Bluetooth LE, NFC, vendor-specific hardware SDK, or file system operations below what react-native-fs provides. In 2024, React Native offers two paths: the old architecture (Bridge) and the new one (JSI + TurboModules). Projects created with react-native init version 0.74+ use the new architecture by default.
Old Architecture: Bridge Module
For projects on RN < 0.68 or with the new architecture disabled:
// BluetoothModule.kt
class BluetoothModule(private val reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "BluetoothModule"
@ReactMethod
fun isBluetoothEnabled(promise: Promise) {
val bluetoothManager = reactContext.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
promise.resolve(bluetoothManager.adapter?.isEnabled ?: false)
}
@ReactMethod
fun startScan(promise: Promise) {
val scanner = (reactContext.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager)
.adapter?.bluetoothLeScanner
if (scanner == null) {
promise.reject("BT_ERROR", "Bluetooth LE not supported")
return
}
// start scanning
promise.resolve(null)
}
// Send events to JS
private fun sendEvent(eventName: String, params: WritableMap?) {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(eventName, params)
}
}
Register via Package:
class BluetoothPackage : ReactPackage {
override fun createNativeModules(context: ReactApplicationContext) =
listOf(BluetoothModule(context))
override fun createViewManagers(context: ReactApplicationContext) = emptyList<ViewManager<*, *>>()
}
Add to MainApplication.kt in the getPackages() method.
New Architecture: TurboModule + JSI
With RN 0.74+ and newArchEnabled=true, implement the module as a TurboModule via Codegen. First, a TypeScript specification:
// NativeBluetoothModule.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
isBluetoothEnabled(): Promise<boolean>;
startScan(): Promise<void>;
addListener(eventType: string): void;
removeListeners(count: number): void;
}
export default TurboModuleRegistry.getEnforcing<Spec>('BluetoothModule');
Codegen generates NativeBluetoothModuleSpec.kt from this specification. The native implementation inherits the generated abstract class:
class BluetoothModule(context: ReactApplicationContext) :
NativeBluetoothModuleSpec(context) {
override fun getName() = NAME
override fun isBluetoothEnabled(): Promise<Boolean> {
// implementation identical to bridge variant
}
companion object {
const val NAME = "BluetoothModule"
}
}
The key difference: TurboModule is called directly via JSI, without JSON bridge serialization. For high-frequency calls (audio stream, sensors), this is critical—latency drops from tens of milliseconds to single digits.
Sending Events from Native Code to JavaScript
The pattern is the same for both architectures—RCTDeviceEventEmitter:
fun emitScanResult(deviceAddress: String, rssi: Int) {
val params = Arguments.createMap().apply {
putString("address", deviceAddress)
putInt("rssi", rssi)
}
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("onBluetoothDeviceFound", params)
}
On the JS side:
import { NativeEventEmitter, NativeModules } from 'react-native';
const { BluetoothModule } = NativeModules;
const emitter = new NativeEventEmitter(BluetoothModule);
useEffect(() => {
const subscription = emitter.addListener('onBluetoothDeviceFound', (event) => {
console.log('Found device:', event.address, 'RSSI:', event.rssi);
});
return () => subscription.remove();
}, []);
subscription.remove() in the cleanup of useEffect is mandatory. Subscription leaks cause handler invocation after component unmount and potential state updates on unmounted components.
Handling Permissions Inside the Module
The native module should not request runtime permissions itself—that's the responsibility of the JS layer via react-native-permissions or the built-in PermissionsAndroid. The module only checks if permission is granted and returns an appropriate error:
@ReactMethod
fun startScan(promise: Promise) {
if (ActivityCompat.checkSelfPermission(
reactContext,
Manifest.permission.BLUETOOTH_SCAN
) != PackageManager.PERMISSION_GRANTED) {
promise.reject("PERMISSION_DENIED", "BLUETOOTH_SCAN permission required")
return
}
// continuation
}
Debugging and Common Errors
Module not found: null is not an object (evaluating 'NativeModules.BluetoothModule.isBluetoothEnabled')—Package not added to getPackages() or typo in getName(). Check Metro bundler logs and adb logcat.
Calling native method from background thread updates UI. React Native bridge and JSI are not main thread. If updating any Android UI element inside @ReactMethod, use Handler(Looper.getMainLooper()).post { ... }.
WritableMap cannot be used after passing to promise. Arguments.createMap() creates a one-time object. After promise.resolve(map), the object is invalid—re-reading it causes IllegalStateException.
Compatibility between old and new architectures. Until the project fully transitions to the new architecture, the module must support both. ReactPackage remains, TurboModule implementation is added on top. In build.gradle, use conditional compilation with isNewArchEnabled.
Testing
Native modules are tested at the Kotlin level (JUnit + Mockk for ReactApplicationContext) and at integration level via Detox or Maestro. Isolated bridge method test:
@Test
fun `isBluetoothEnabled returns false when adapter is null`() {
val context = mockk<ReactApplicationContext>()
every { context.getSystemService(Context.BLUETOOTH_SERVICE) } returns mockk<BluetoothManager> {
every { adapter } returns null
}
val module = BluetoothModule(context)
val promise = mockk<Promise>(relaxed = true)
module.isBluetoothEnabled(promise)
verify { promise.resolve(false) }
}
Developing a Native Module: basic module with 3–5 methods takes 2–4 days. Module with events, support for both architectures, and tests takes a week or more. Integrating heavy vendor SDK is calculated individually. Cost is determined after analyzing requirements.







