Native Modules
React Native native modules — TurboModules, Fabric components, Codegen, platform channels
Native Modules
React Native lets JS call into native iOS/Android code. The mechanism evolved significantly with the New Architecture (Fabric + TurboModules + Codegen). This page focuses on the modern approach with notes on legacy code you may still encounter.
When You Actually Need a Native Module
Before reaching for native code, check if it already exists:
- Community modules first: most needs (camera, biometrics, geolocation, BLE, push) have battle-tested libraries.
- Expo modules: many features are available without ejecting.
- Native module reasons that hold up:
- Calling a vendor SDK that ships only as a native library (payment, identity, telco-specific).
- Performance-critical work that cannot afford bridge round-trips.
- Accessing a platform API React Native has not exposed.
If you're authoring a module, ship it as TypeScript-first using the New Architecture from day one.
Old vs New Architecture
| Aspect | Old (Bridge) | New (JSI + Fabric + TurboModules) |
|---|---|---|
| Calls | Async, JSON-serialized | Sync or async, typed via JSI |
| Method discovery | Runtime registry | Codegen from TS spec |
| Renderer | Paper (legacy) | Fabric (concurrent-rendering aware) |
| Components | requireNativeComponent | Codegen Fabric components |
| Performance | Bridge bottleneck | Direct memory, no serialization |
The New Architecture has been default in templates since RN 0.76. New work should target it. Old NativeModules.XYZ.method() code still runs via an interop layer but should be migrated.
TurboModule Workflow
A TurboModule is a typed, codegen-driven native module.
1. Declare the JS Spec
// specs/NativeOneNzBiometrics.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
isAvailable(): Promise<boolean>;
authenticate(reason: string): Promise<{
success: boolean;
error?: string;
}>;
getEnrolledType(): string; // 'face' | 'fingerprint' | 'none'
}
export default TurboModuleRegistry.getEnforcing<Spec>('OneNzBiometrics');The file must be named Native*.ts and exported as default. Codegen reads this.
2. Register in package.json for Codegen
{
"codegenConfig": {
"name": "OneNzBiometricsSpec",
"type": "modules",
"jsSrcsDir": "./specs",
"android": { "javaPackageName": "nz.one.biometrics" }
}
}3. iOS Implementation
// ios/OneNzBiometrics.mm
#import "OneNzBiometrics.h"
#import <LocalAuthentication/LocalAuthentication.h>
@implementation OneNzBiometrics
RCT_EXPORT_MODULE()
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
(const facebook::react::ObjCTurboModule::InitParams &)params {
return std::make_shared<facebook::react::NativeOneNzBiometricsSpecJSI>(params);
}
- (void)authenticate:(NSString *)reason
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject {
LAContext *ctx = [LAContext new];
NSError *err;
if (![ctx canEvaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics error:&err]) {
resolve(@{ @"success": @NO, @"error": err.localizedDescription });
return;
}
[ctx evaluatePolicy:LAPolicyDeviceOwnerAuthenticationWithBiometrics
localizedReason:reason
reply:^(BOOL success, NSError * _Nullable evalErr) {
resolve(@{ @"success": @(success), @"error": evalErr.localizedDescription ?: [NSNull null] });
}];
}
@end4. Android Implementation
// android/src/main/java/nz/one/biometrics/OneNzBiometricsModule.kt
package nz.one.biometrics
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import com.facebook.react.bridge.*
class OneNzBiometricsModule(reactContext: ReactApplicationContext) :
NativeOneNzBiometricsSpec(reactContext) {
override fun getName() = NAME
override fun isAvailable(promise: Promise) {
val mgr = BiometricManager.from(reactApplicationContext)
val ok = mgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
BiometricManager.BIOMETRIC_SUCCESS
promise.resolve(ok)
}
override fun authenticate(reason: String, promise: Promise) {
// BiometricPrompt setup, resolve with WritableMap result
}
companion object { const val NAME = "OneNzBiometrics" }
}5. Use From JS
import Biometrics from './specs/NativeOneNzBiometrics';
if (await Biometrics.isAvailable()) {
const result = await Biometrics.authenticate('Confirm payment');
if (result.success) { /* ... */ }
}Fabric Components
When you need to render a native view (a map, a video player, a custom chart), you write a Fabric component.
1. Declare the Component Spec
// specs/OneNzMapNativeComponent.ts
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import type { ViewProps } from 'react-native';
import type { Float, Int32 } from 'react-native/Libraries/Types/CodegenTypes';
interface Region {
latitude: Float;
longitude: Float;
latitudeDelta: Float;
longitudeDelta: Float;
}
export interface NativeProps extends ViewProps {
region: Region;
showsUserLocation?: boolean;
zoomLevel?: Int32;
}
export default codegenNativeComponent<NativeProps>('OneNzMapView');2. Implement Native View
iOS: subclass RCTViewComponentView and override updaters. Android: extend ViewGroupManager / SimpleViewManager and implement the generated spec interface.
3. Use From JS
import OneNzMapView from './specs/OneNzMapNativeComponent';
<OneNzMapView
region={{ latitude: -36.85, longitude: 174.76, latitudeDelta: 0.05, longitudeDelta: 0.05 }}
showsUserLocation
style={{ flex: 1 }}
/>Performance Considerations
Synchronous Calls
TurboModules can be sync — useful for getters and small values.
// Sync API: returns immediately, no promise
getEnrolledType(): string;Trade-off: a sync method runs on the JS thread. If it does heavy work, it blocks rendering. Reserve sync for cheap reads.
Avoid Crossing Per Frame
Even with JSI, calling native every frame is wasteful. Use react-native-reanimated worklets for animation logic, or push state into the native component via props and let it self-update.
Use Events for Push
Native → JS should use the event emitter pattern, not polling.
// Native side emits 'BiometricStateChanged'
import { DeviceEventEmitter } from 'react-native';
const sub = DeviceEventEmitter.addListener('BiometricStateChanged', (state) => { /* ... */ });
// Always remove
return () => sub.remove();Native Module Anti-Patterns
Sync Methods That Hit Disk or Network
A sync TurboModule that does I/O freezes the JS thread. Always async those.
Returning Massive Objects
JSI is faster than the bridge, but still has marshalling cost for large structures. Slice the data or return an opaque handle.
Per-Method Threading Decisions
Document the threading contract for each method (UI thread, background, callback queue). Inconsistency causes hard-to-diagnose UI freezes and threading bugs.
Reaching Around the Spec
Once you have a Codegen spec, treat it as the contract. Adding methods directly via RCT_EXPORT_METHOD outside the spec bypasses types — strict mode will error.
Platform Channels: Mapping From Flutter
If you come from Flutter, the analogy:
| Flutter | React Native |
|---|---|
| MethodChannel | TurboModule method (async) |
| EventChannel | TurboModule event emitter |
| BasicMessageChannel | Codegen typed methods |
| Platform View (AndroidView / UiKitView) | Fabric component |
| Pigeon | Codegen (from TS spec) |
Migration Checklist (Old → New Architecture)
- Update
package.jsoncodegenConfig. - Replace
NativeModules.XwithTurboModuleRegistry.getEnforcing<Spec>('X'). - Convert
requireNativeComponenttocodegenNativeComponent. - Implement new spec methods on each platform (you keep the existing class, add the new interface).
- Enable New Architecture in
Podfile(:fabric_enabled => true, :hermes_enabled => true) andgradle.properties(newArchEnabled=true). - Test on both platforms — type mismatches surface here that were silent before.