Native modules

Writing a React Native native module in Swift and Kotlin

When no package does what you need, you write the bridge yourself. A complete module on both platforms, the threading rule that catches everyone, and how to tell when you should not write one at all.

12 min read

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:

  1. 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.
  2. 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.
  3. 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)

@end

The 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 @objc annotation and the RCT_EXTERN_METHOD declaration 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 resolve or reject exactly 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.

Start with a conversation.

Send us the problem, not a specification. We read every message ourselves and reply personally — and if we are not the right team for it, we will say so and usually point you to someone who is.

  • Read by an engineer
  • Answered personally
  • No sales sequence