Developing Native Module for React Native App (iOS)
The bridge between JavaScript and native Swift/Objective-C is one of the trickiest layers in React Native. While the app works only with JS libraries, everything is relatively predictable. But once a task appears that can't be closed with standard package — working with Bluetooth Low Energy via CoreBluetooth, accessing protected Keychain via SecItemCopyMatching, integrating third-party bank or payment system SDK — you have to write Native Module manually.
And that's where it begins.
Bridge Architecture: Old and New
Before React Native 0.71, the bridge worked through an asynchronous message queue: JS thread serialized the call to JSON, sent through bridge, native thread deserialized and executed. Latency was acceptable for most tasks, but with high-frequency calls (for example, UI update from sensor data) it became noticeable.
Starting with version 0.68, New Architecture appeared — JSI (JavaScript Interface) + Turbo Modules. JSI allows calling native code synchronously through C++ host object, bypassing message queue. This fundamentally changes the approach to writing modules: instead of RCTBridgeModule you need to implement TurboModule protocol through code generation based on Flow/TypeScript specification.
In practice, most projects still sit on old architecture because updating breaks dependencies. So we support both approaches.
Old Architecture: RCTBridgeModule
Typical structure — Swift class inherited from NSObject with @objc attributes:
@objc(BiometricModule)
class BiometricModule: NSObject, RCTBridgeModule {
static func moduleName() -> String { "BiometricModule" }
@objc func authenticate(_ reason: String,
resolver: @escaping RCTPromiseResolveBlock,
rejecter: @escaping RCTPromiseRejectBlock) {
let context = LAContext()
var error: NSError?
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
rejecter("BIOMETRIC_UNAVAILABLE", error?.localizedDescription, error)
return
}
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
localizedReason: reason) { success, authError in
if success { resolver(true) }
else { rejecter("AUTH_FAILED", authError?.localizedDescription, authError) }
}
}
}
Registration via RCT_EXTERN_MODULE in Objective-C bridging file is mandatory — without it the module doesn't appear in registry.
Most common error at this stage: developer writes Swift class, forgets to add @objc(BiometricModule) or incorrectly names method in RCT_EXTERN_METHOD, and on JS side gets undefined is not a function. Hard to debug because error appears at runtime without stacktrace.
Where Time Really Gets Spent
Thread safety. React Native calls module methods on arbitrary thread from its pool. If you access UIKit inside method — crash with UIKit called from background thread. Classic solution — DispatchQueue.main.async { } around UI code. But this creates new problem: resolve/reject are called asynchronously, and if user managed to close screen, completion handler accesses already freed object.
Pattern with [weak self] and guard is mandatory:
DispatchQueue.main.async { [weak self] in
guard self != nil else { return }
resolver(result)
}
Data serialization. Bridge accepts only types it can serialize to JSON: NSString, NSNumber, NSArray, NSDictionary, NSNull. Want to transfer Data (binary data) — encode to Base64. Want to transfer custom object — break it down to dictionary on native side. This is especially painful with CoreBluetooth when you need to return CBCharacteristic with all its properties.
Callbacks vs Promises vs Events. For one-time results — Promise. For stream of events (sensor data, connection status) — RCTEventEmitter. Mixing approaches in one module is mistake leading to memory leaks: if you save RCTResponseSenderBlock as property and call twice, app crashes with Tried to call a callback that is no longer valid.
New Architecture: Turbo Modules + Codegen
Starting with RN 0.70+, Codegen generates C++ abstraction from TypeScript specification. Spec file looks like:
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
authenticate(reason: string): Promise<boolean>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('BiometricModule');
On native side implement NativeBiometricModuleSpec protocol that Codegen generated automatically. JSI allows calling methods synchronously without JSON serialization — speed is fundamentally different.
Problem: if project has at least one package without Turbo Module support, New Architecture will work in compatibility mode, partially losing benefits.
Approach to Implementation
Audit starts with analyzing current RN version, availability of JSI-compatible packages, and target iOS deployment. If project is on 0.72+ and team is ready for New Architecture — write Turbo Module with Codegen right away. If not — classic RCTBridgeModule with eye to future migration.
Unit test coverage of native part via XCTest is mandatory. Integration tests — via Detox or Jest with module mock on JS side.
Document public API in TypeScript types so team doesn't dig into native code every time.
What's Included
- Analyzing requirements and choosing architectural approach (Old Bridge / Turbo Module)
- Writing native code in Swift with Objective-C bridging
- TypeScript typing of module public API
- Error handling, thread safety
- Unit tests of native part (XCTest)
- Integration with JS layer, checking in simulator and on real device
- Module usage documentation
Timeline
From 3 to 5 days depending on complexity of native API that needs to be wrapped. Simple wrapper over one system framework — closer to 3 days. Module with event stream, binary data, and New Architecture support — 5 days and more. Pricing is calculated individually after analyzing requirements and codebase.







