Writing your first custom native module in React Native
· 3 min read · Amrith Vengalath
- React Native
- Native Modules
- iOS
- Android
Most of the time, someone has already wrapped the native feature you need. But every now and then you hit something with no library, or a library that's abandoned, or a vendor SDK that only ships iOS and Android code. That's when you write a native module yourself - a bridge between your JavaScript and the platform's native APIs.
The first time I did this it felt intimidating. It isn't, really. It's mostly plumbing once you've seen the shape of it. Here's a minimal example: a module that reads a value the app needs from the native side.
The shape of a native module
A native module is native code (Objective-C/Swift on iOS, Java/Kotlin on Android) that you register with React Native and call from JavaScript. The bridge handles passing arguments and results across, with one big constraint: everything that crosses is serializable - strings, numbers, booleans, arrays, maps. No native objects come through directly, and the calls are asynchronous.
iOS side
In the iOS project, you expose a method to React Native. Here's a module that returns the device's preferred language, using a promise so the JS side can await it:
// DeviceInfoModule.m
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(DeviceInfoModule, NSObject)
RCT_EXTERN_METHOD(getPreferredLanguage:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
@end// DeviceInfoModule.swift
@objc(DeviceInfoModule)
class DeviceInfoModule: NSObject {
@objc static func requiresMainQueueSetup() -> Bool { return false }
@objc func getPreferredLanguage(_ resolve: RCTPromiseResolveBlock,
rejecter reject: RCTPromiseRejectBlock) {
let lang = Locale.preferredLanguages.first ?? "en"
resolve(lang)
}
}That requiresMainQueueSetup is one of those things you'll get a warning about if you skip it. Return true only if your module touches UIKit on init; otherwise false keeps it off the main thread at startup.
Android side
The Android equivalent - a module class plus a package that registers it:
// DeviceInfoModule.kt
class DeviceInfoModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName() = "DeviceInfoModule"
@ReactMethod
fun getPreferredLanguage(promise: Promise) {
try {
val lang = reactApplicationContext.resources
.configuration.locales[0].language
promise.resolve(lang)
} catch (e: Exception) {
promise.reject("ERR_LANGUAGE", e)
}
}
}The method name and the module name (getName()) are what tie it back to JavaScript. They have to match what you call. The number of times I've debugged a "undefined is not a function" only to find a typo between getName() and the JS reference is more than I'd like to admit.
Calling it from JavaScript
import { NativeModules } from "react-native";
const { DeviceInfoModule } = NativeModules;
const lang = await DeviceInfoModule.getPreferredLanguage();I usually wrap this in a small JS module so the rest of the app never touches NativeModules directly - it gives you one place to add types, defaults, and a fallback if the native side ever isn't there.
The gotchas that aren't obvious
- Everything is async. Even reading a value comes back as a promise (or a callback). You can't write a synchronous getter through the standard bridge.
- Only serializable data crosses. Want to pass a native object? You can't. You convert it to a map/array of primitives on the native side first.
- You have to rebuild the native app. Changing native code and reloading JS does nothing - Metro only reloads JavaScript. New native code means a fresh
pod install/ Gradle build. I've stared at "why isn't my change showing" too many times before remembering this. - Two platforms, two implementations, one JS surface. Keep the method signatures identical across iOS and Android so the JavaScript doesn't have to branch.
When to actually do this
If a maintained library exists, use it - writing and maintaining native code across two platforms is real ongoing cost. But when there's genuinely nothing, this is a normal, learnable part of the job, not deep wizardry. Once you've written one, the next is mostly copy-paste-and-adjust.
(One note for the future: this is the "old architecture" bridge. The New Architecture replaces it with TurboModules and codegen, which changes the boilerplate. But the bridge is still everywhere, and understanding it is the foundation for understanding what comes next.)