Most React Native applications never need a native module. The ecosystem covers camera, storage, notifications, biometrics and almost everything else a normal product touches, and reaching for Swift before checking is how teams acquire code nobody wants to maintain.
But the cases that remain are real: a device SDK with no JavaScript wrapper, a platform API too new for anyone to have wrapped it, a maintained package that does nine tenths of what you need, or work that has no business running on the JavaScript thread at all. This is how you write one, on both platforms, end to end.
First, check whether you need one
Three questions, in this order:
- Does a maintained package already exist? Check when it last shipped, whether it supports your React Native version, and whether the issues are being answered. A maintained package you did not write beats a native module you did.
- Can it be done in JavaScript at acceptable cost? If the answer is yes and the cost is acceptable, do that. A native module doubles your platform surface permanently.
- Does the work belong off the JS thread? Image processing, cryptography, continuous sensor streams — these are a reason to go native even when a JavaScript implementation exists.
If you get through all three and still need one, the rest of this is for you.
What a native module actually is
A native module is a class on the platform side that declares some of its methods callable from JavaScript. React Native gives you a registry of those classes, and your JavaScript gets an object whose methods return promises.
You write it twice — once in Swift or Objective-C for iOS, once in Kotlin or Java for Android — and expose the same method names from both, so the JavaScript side sees one interface.
The worked example below sets screen brightness. It is deliberately small, it genuinely lacks a good cross-platform package, and it has to touch the UI thread on both platforms — which is the part people get wrong.
The iOS side, in Swift
Swift classes are not visible to React Native’s Objective-C runtime on their own. You need two files: the Swift implementation, and a small Objective-C file that declares what is exported.
// RNBrightness.swift
import Foundation
import UIKit
@objc(RNBrightness)
class RNBrightness: NSObject {
// Return false unless the module has to be initialised on the main
// queue. Returning true when you do not need it slows startup.
@objc
static func requiresMainQueueSetup() -> Bool {
return false
}
@objc(setLevel:resolver:rejecter:)
func setLevel(
_ level: NSNumber,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock
) {
let value = CGFloat(truncating: level)
guard value >= 0, value <= 1 else {
reject("E_RANGE", "Brightness must be between 0 and 1", nil)
return
}
// UIScreen is main-thread only.
DispatchQueue.main.async {
UIScreen.main.brightness = value
resolve(nil)
}
}
}And the bridging file that makes it visible. The selector in RCT_EXTERN_METHOD must match the @objc(setLevel:resolver:rejecter:) annotation exactly — a mismatch compiles cleanly and then fails at runtime with an unrecognised selector, which is a genuinely miserable afternoon.
// RNBrightness.m
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(RNBrightness, NSObject)
RCT_EXTERN_METHOD(setLevel:(nonnull NSNumber *)level
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
@endThe first time you add a Swift file to an iOS project that did not have one, Xcode offers to create a bridging header. Accept it.
The Android side, in Kotlin
Android needs the module itself and a package class that registers it.
// RNBrightnessModule.kt
package com.yourapp.brightness
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
class RNBrightnessModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
// This string is the name JavaScript will use.
override fun getName() = "RNBrightness"
@ReactMethod
fun setLevel(level: Double, promise: Promise) {
if (level < 0 || level > 1) {
promise.reject("E_RANGE", "Brightness must be between 0 and 1")
return
}
val activity = currentActivity
if (activity == null) {
promise.reject("E_NO_ACTIVITY", "No activity is attached")
return
}
// Window attributes must be set on the UI thread.
activity.runOnUiThread {
val params = activity.window.attributes
params.screenBrightness = level.toFloat()
activity.window.attributes = params
promise.resolve(null)
}
}
}// RNBrightnessPackage.kt
package com.yourapp.brightness
import android.view.View
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class RNBrightnessPackage : ReactPackage {
override fun createNativeModules(
reactContext: ReactApplicationContext
): List<NativeModule> = listOf(RNBrightnessModule(reactContext))
override fun createViewManagers(
reactContext: ReactApplicationContext
): List<ViewManager<out View, *>> = emptyList()
}Then register the package in your MainApplication, wherever the existing packages are listed.
Calling it from TypeScript
Do not let the raw NativeModules object leak into your application code. Wrap it once, type it, and handle the case where the module is missing — which happens on a stale build, and produces an error far more confusing than the one you can raise yourself.
// brightness.ts
import { NativeModules } from 'react-native';
type BrightnessModule = {
setLevel(level: number): Promise<void>;
};
const native: BrightnessModule | undefined = NativeModules.RNBrightness;
export async function setBrightness(level: number): Promise<void> {
if (!native) {
throw new Error(
'RNBrightness is unavailable. Rebuild the app after adding the module.',
);
}
return native.setLevel(level);
}Adding a native module means rebuilding the app. Reloading JavaScript is not enough, and forgetting this is the single most common reason a newly written module appears not to exist.
Threading, which is the part that bites
Your module methods do not run on the JavaScript thread, and on Android they do not run on the UI thread either. If you touch anything belonging to the interface, you have to get yourself onto the right thread — DispatchQueue.main.async on iOS, runOnUiThread on Android, both shown above.
The failure mode is unpleasant precisely because it is inconsistent. UI work off the main thread may appear to succeed in development, then crash on a device under load, or simply do nothing. If your module touches a view, a window or the screen, assume it needs the main thread.
The reverse also matters: heavy work should not be pushed onto the main thread. Do the expensive part on a background queue and hop to the main thread only for the part that requires it.
Where the New Architecture changes this
Everything above uses the long-standing module API, which is still supported and still what a great deal of production code uses. React Native’s New Architecture introduces TurboModules, where you declare the interface once in a typed specification and code generation produces the native scaffolding, and calls go through JSI rather than the asynchronous bridge — which allows synchronous calls and removes a serialisation step.
It is the default in recent React Native versions. Check what your project is actually on before choosing an approach, and if you are starting a new module on a current version, write it as a TurboModule. The concepts in this post — the two platform implementations, the threading rules, the typed wrapper — carry across unchanged; what changes is the registration mechanism.
What usually goes wrong
- Selector mismatch on iOS. The
@objcannotation and theRCT_EXTERN_METHODdeclaration must agree exactly, including argument labels. It compiles either way. - Forgetting to rebuild. New native code needs a native build, not a JavaScript reload.
- UI work on the wrong thread. Intermittent, and usually worse on real devices than in the simulator.
- Promises that never settle. Every path through the method must call
resolveorrejectexactly once. An early return that does neither leaves the JavaScript side awaiting forever, with no error. - Rejecting with unhelpful codes. Give each failure a stable code you can branch on later, rather than one generic error.
- Writing one at all. If a maintained package appears six months later, delete yours. Owning less native code is the goal, not a defeat.
Owning the module afterwards
A native module is a permanent commitment to two more toolchains. It will break on an operating system upgrade eventually, and whoever maintains the app afterwards needs to be able to read Swift and Kotlin. That is a real cost and worth naming before you take it on.
It is also, quite often, the only way to build the thing the product actually needs — which is why we spend a lot of time here. If you have a React Native app blocked on something the ecosystem does not cover, that is the work we do: see React Native development, or the performance post for the cases where moving work to native is the fix.