$ cat posts/react-native-native-modules.md

# Native integrations in React Native - when JavaScript is not enough

@dateDecember 1, 2025
@read6 min read

React Native abstracts away much of the complexity of native platforms. But there comes a time when you need to go beyond what JavaScript offers. Whether to access a specific system API, integrate an SDK that only exists in Swift or Kotlin, or simply extract more performance from a critical operation.

 

In this article, I will explore how communication between JavaScript and native code works, how to create your own modules, and when this approach makes sense.

 

The architecture behind the bridge

Before writing any native code, it's essential to understand how React Native connects the two worlds. The classic architecture uses an asynchronous bridge that serializes data to JSON and sends it between JavaScript and native threads.

 

In the new architecture (Fabric and TurboModules), this communication has evolved. TurboModules use JSI (JavaScript Interface) to allow synchronous calls and direct access to C++ objects, eliminating JSON serialization and drastically reducing latency.

 

If you want to understand more about how React Native architecture works, I recommend reading the article Understanding React Native Architecture.

 

If you're creating a new module, consider TurboModules from the start. The implementation cost is higher, but the performance gains are significant for frequent operations.

 

Creating a native module on Android

Let's create a simple module that returns the battery level. First, we create the module class in Kotlin:

 

// android/app/src/main/java/com/yourapp/BatteryModule.kt
package com.yourapp

import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
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 BatteryModule(reactContext: ReactApplicationContext) :
    ReactContextBaseJavaModule(reactContext) {

    override fun getName() = "BatteryModule"

    @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 batteryPct = level * 100 / scale.toFloat()
            promise.resolve(batteryPct.toDouble())
        } catch (e: Exception) {
            promise.reject("ERROR", e.message)
        }
    }
}

 

Then, we register the module in a Package:

 

// android/app/src/main/java/com/yourapp/BatteryPackage.kt
package com.yourapp

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

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

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

 

Finally, we add the package to the package list in MainApplication:

 

// android/app/src/main/java/com/yourapp/MainApplication.kt
package com.yourapp

import android.app.Application
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.facebook.react.defaults.DefaultReactNativeHost

class MainApplication : Application(), ReactApplication {

    override val reactNativeHost: ReactNativeHost =
        object : DefaultReactNativeHost(this) {
            override fun getPackages(): List<ReactPackage> =
                PackageList(this).packages.apply {
                    add(BatteryPackage()) // Add your package here
                }

            override fun getJSMainModuleName(): String = "index"
            override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
            override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
            override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
        }

    override val reactHost: ReactHost
        get() = getDefaultReactHost(applicationContext, reactNativeHost)

    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
            load()
        }
    }
}

 

The key point is adding BatteryPackage() inside the getPackages() method. That's where React Native discovers which native modules are available.

 

Creating the same module on iOS

On iOS, we create the module in Swift. First, the implementation file:

 

// ios/BatteryModule.swift
import Foundation
import UIKit

@objc(BatteryModule)
class BatteryModule: NSObject {

    @objc
    func getBatteryLevel(
        _ resolve: @escaping RCTPromiseResolveBlock,
        rejecter reject: @escaping RCTPromiseRejectBlock
    ) {
        DispatchQueue.main.async {
            UIDevice.current.isBatteryMonitoringEnabled = true
            let batteryLevel = UIDevice.current.batteryLevel

            if batteryLevel < 0 {
                reject("ERROR", "Could not get battery level", nil)
            } else {
                resolve(Double(batteryLevel * 100))
            }
        }
    }

    @objc
    static func requiresMainQueueSetup() -> Bool {
        return false
    }
}

 

And the macro file to expose to React Native:

 

// ios/BatteryModule.m
#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(BatteryModule, NSObject)

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

@end

 

Consuming the module in JavaScript

With the module registered on both platforms, usage in JavaScript is straightforward:

 

import { NativeModules } from 'react-native';

const { BatteryModule } = NativeModules;

async function checkBattery() {
    try {
        const level = await BatteryModule.getBatteryLevel();
        console.log(`Battery: ${level}%`);
    } catch (error) {
        console.error('Error getting battery:', error);
    }
}

 

For larger projects, it's worth creating a TypeScript wrapper with well-defined types and centralized error handling.

 

Sending events from native to JavaScript

Often, the flow is reversed: native code needs to notify JavaScript about something that happened. For this, we use event emitters.

 

On Android:

 

private fun sendEvent(eventName: String, params: WritableMap?) {
    reactApplicationContext
        .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
        .emit(eventName, params)
}

 

On iOS:

 

@objc(BatteryModule)
class BatteryModule: RCTEventEmitter {

    override func supportedEvents() -> [String]! {
        return ["onBatteryChange"]
    }

    func notifyBatteryChange(level: Float) {
        sendEvent(withName: "onBatteryChange", body: ["level": level])
    }
}

 

In JavaScript, we listen to the event:

 

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

const { BatteryModule } = NativeModules;
const eventEmitter = new NativeEventEmitter(BatteryModule);

useEffect(() => {
    const subscription = eventEmitter.addListener(
        'onBatteryChange',
        (event) => {
            console.log('Battery changed:', event.level);
        }
    );

    return () => subscription.remove();
}, []);

 

When to create a native module

Before jumping into native code, evaluate if you really need it. There are clear scenarios where it makes sense:

 

  • Critical performance: operations that need to run in less than 16ms to not block the UI.
  • System APIs: access to resources like Bluetooth LE, NFC, specific sensors, or accessibility APIs.
  • Proprietary SDKs: integration with payment, analytics, or authentication SDKs that only exist in native code.
  • Heavy processing: encryption, image compression, or video manipulation.

 

On the other hand, avoid creating native modules for things that existing libraries already solve well. Maintaining native code on two platforms has a significant cost.

 

TurboModules and the new architecture

If you're on a project that has already migrated to the new architecture, TurboModules offer important advantages:

 

  • Lazy loading: modules are only loaded when first used.
  • Synchronous calls: when necessary, you can make synchronous calls to native code.
  • Static typing: the module specification is done in TypeScript, generating native code automatically.

 

The implementation is more verbose, but React Native tooling generates much of the boilerplate. It's worth the investment for modules that will be used frequently.

 

Debugging and common pitfalls

Working with native code brings its own debugging challenges:

 

  • Native logs: use Logcat (Android) and Console (Xcode) to see native code logs. They don't appear in Metro.
  • Thread safety: JavaScript calls arrive on a specific thread. UI operations must be dispatched to the main thread.
  • Lifecycle: native modules can be recreated during reloads. Clean up resources in the invalidate method.
  • Data types: the bridge converts types automatically, but not everything is supported. Maps and Arrays work, but complex objects need to be serialized.

 

Conclusion

Native integrations are a powerful tool in a React Native developer's arsenal. They allow you to go beyond JavaScript's limitations and access the full potential of the platforms.

 

But with that power comes responsibility. Native code means maintaining two implementations, dealing with each platform's peculiarities, and more complex debugging. Use it when necessary, but not by default.

 

If you work on apps that require performance or specific integrations, mastering native modules will set you apart. It's a skill that separates beginner React Native developers from advanced ones.