Skip to content

Latest commit

 

History

History
654 lines (492 loc) · 19.5 KB

File metadata and controls

654 lines (492 loc) · 19.5 KB

Native Modules & Bridge

Navigation: ← Data Persistence | New Architecture →


Table of Contents


Overview

React Native's power lies in accessing native platform capabilities while writing most of your app in JavaScript/TypeScript. Understanding the bridge architecture, JSI, and how to write native modules is essential for senior React Native roles where you need to integrate SDKs, optimize performance, or access platform APIs not available in JS.

This guide covers the bridge-to-JSI evolution, native module development on iOS and Android, event communication, and decision criteria for going native.


Theory

The Old Bridge Architecture

The original React Native architecture uses an asynchronous, serialized bridge between JavaScript and native code:

JavaScript Thread ←→ Bridge (JSON) ←→ Native Modules Thread
                                    ←→ UI Thread (iOS main / Android UI)

How it works:

  1. JS calls a native module method
  2. Arguments are JSON-serialized and queued as a message
  3. Message crosses the bridge asynchronously
  4. Native module executes on the native modules thread
  5. Result is serialized back and sent to JS thread
  6. Promise resolves in JavaScript

Problems:

  • Asynchronous only — No synchronous native calls
  • Serialization overhead — JSON encode/decode for every call
  • Batching delays — Messages queued and processed in batches (~16ms)
  • No direct memory access — Cannot share objects between JS and native
  • Single-threaded bottleneck — All native module calls share one queue

JavaScript Interface (JSI)

JSI is a lightweight C++ API that allows JavaScript to hold references to native objects and invoke methods directly without serialization:

JavaScript ←→ JSI (C++ direct calls) ←→ Native Code

Benefits:

  • Synchronous calls — Read a value without async overhead
  • No serialization — Direct memory access, shared objects
  • Smaller overhead — No message queue, no JSON
  • Foundation for New Architecture — Fabric and TurboModules built on JSI

JSI is engine-agnostic — works with Hermes, JavaScriptCore, and V8.

Writing Native Modules

Native modules expose platform functionality to JavaScript. Two approaches:

Legacy Native Modules (Bridge):

  • iOS: Objective-C/Swift classes conforming to RCTBridgeModule
  • Android: Java/Kotlin classes extending ReactContextBaseJavaModule

TurboModules (New Architecture):

  • Spec defined in TypeScript/Flow with Codegen
  • C++ JSI bindings generated automatically
  • Lazy-loaded, type-safe, synchronous-capable

Event Emitters

Native-to-JS communication uses events:

  • Legacy: RCTEventEmitter (iOS) / DeviceEventManagerModule (Android)
  • TurboModules: EventEmitter with typed events via Codegen
  • JS side: NativeEventEmitter to subscribe

Use events for: sensor data, Bluetooth callbacks, download progress, keyboard events.

When to Go Native

Go native when:

  • No existing library covers the use case
  • Performance-critical operations (image processing, encryption, ML inference)
  • Platform-specific APIs (HealthKit, ARKit, Android Auto)
  • Third-party SDK integration (payment, analytics, maps)
  • Background processing requirements

Stay in JavaScript when:

  • Existing library meets requirements
  • UI-only features
  • Business logic and data transformation
  • Rapid prototyping

Code Examples

TypeScript Spec for TurboModule (Codegen)

// specs/NativeBattery.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  getBatteryLevel(): Promise<number>;
  isCharging(): boolean;
  startMonitoring(): void;
  stopMonitoring(): void;
  addListener(eventType: string): void;
  removeListeners(count: number): void;
}

export default TurboModuleRegistry.getEnforcing<Spec>('BatteryModule');

iOS Native Module (Legacy Bridge)

// BatteryModule.h
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>

@interface BatteryModule : RCTEventEmitter <RCTBridgeModule>
@end

// BatteryModule.m
#import "BatteryModule.h"
#import <UIKit/UIKit.h>

@implementation BatteryModule

RCT_EXPORT_MODULE(BatteryModule);

+ (BOOL)requiresMainQueueSetup {
  return NO;
}

- (NSArray<NSString *> *)supportedEvents {
  return @[@"BatteryLevelChanged"];
}

RCT_EXPORT_METHOD(getBatteryLevel:(RCTPromiseResolveBlock)resolve
                  rejecter:(RCTPromiseRejectBlock)reject) {
  [UIDevice currentDevice].batteryMonitoringEnabled = YES;
  float level = [UIDevice currentDevice].batteryLevel;

  if (level < 0) {
    reject(@"UNAVAILABLE", @"Battery level unavailable", nil);
  } else {
    resolve(@(level * 100));
  }
}

RCT_EXPORT_METHOD(startMonitoring) {
  [UIDevice currentDevice].batteryMonitoringEnabled = YES;
  [[NSNotificationCenter defaultCenter]
    addObserver:self
    selector:@selector(batteryLevelChanged:)
    name:UIDeviceBatteryLevelDidChangeNotification
    object:nil];
}

- (void)batteryLevelChanged:(NSNotification *)notification {
  float level = [UIDevice currentDevice].batteryLevel;
  [self sendEventWithName:@"BatteryLevelChanged"
                     body:@{@"level": @(level * 100)}];
}

@end

Android Native Module (Legacy Bridge)

// BatteryModule.kt
package com.myapp.modules

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule

class BatteryModule(private val reactContext: ReactApplicationContext) :
    ReactContextBaseJavaModule(reactContext) {

    override fun getName(): String = "BatteryModule"

    private var receiver: BroadcastReceiver? = null

    @ReactMethod
    fun getBatteryLevel(promise: Promise) {
        val batteryManager = reactContext.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
        val level = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
        promise.resolve(level)
    }

    @ReactMethod
    fun startMonitoring() {
        receiver = object : BroadcastReceiver() {
            override fun onReceive(context: Context?, intent: Intent?) {
                val level = intent?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
                val scale = intent?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
                val percentage = (level * 100 / scale.toFloat()).toInt()

                reactContext
                    .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
                    .emit("BatteryLevelChanged", Arguments.createMap().apply {
                        putInt("level", percentage)
                    })
            }
        }
        reactContext.registerReceiver(receiver, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
    }

    @ReactMethod
    fun stopMonitoring() {
        receiver?.let { reactContext.unregisterReceiver(it) }
        receiver = null
    }
}

// BatteryPackage.kt
class BatteryPackage : ReactPackage {
    override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
        return listOf(BatteryModule(reactContext))
    }

    override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
        return emptyList()
    }
}

JavaScript Consumption

import { NativeModules, NativeEventEmitter, Platform } from 'react-native';

const { BatteryModule } = NativeModules;

interface BatteryEvent {
  level: number;
}

class BatteryService {
  private emitter: NativeEventEmitter;
  private subscription: ReturnType<NativeEventEmitter['addListener']> | null = null;

  constructor() {
    this.emitter = new NativeEventEmitter(BatteryModule);
  }

  async getLevel(): Promise<number> {
    return BatteryModule.getBatteryLevel();
  }

  startMonitoring(onLevelChange: (level: number) => void): void {
    BatteryModule.startMonitoring();
    this.subscription = this.emitter.addListener(
      'BatteryLevelChanged',
      (event: BatteryEvent) => onLevelChange(event.level),
    );
  }

  stopMonitoring(): void {
    this.subscription?.remove();
    this.subscription = null;
    BatteryModule.stopMonitoring();
  }
}

export const batteryService = new BatteryService();

React Hook for Native Module

import { useState, useEffect } from 'react';
import { batteryService } from '../services/BatteryService';

export function useBatteryLevel(): { level: number | null; isLoading: boolean } {
  const [level, setLevel] = useState<number | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    batteryService.getLevel()
      .then(setLevel)
      .catch(console.error)
      .finally(() => setIsLoading(false));

    batteryService.startMonitoring(setLevel);
    return () => batteryService.stopMonitoring();
  }, []);

  return { level, isLoading };
}

Interview Questions & Answers

Q1: Explain the old React Native bridge architecture and its limitations.

Answer:

The old bridge is an asynchronous message queue between the JavaScript thread and native threads:

Architecture:

JS Thread ──→ Serialize to JSON ──→ Message Queue ──→ Native Module Thread
JS Thread ←── Deserialize JSON ←── Message Queue ←── Native Module Thread
UI Thread ←── Separate queue ──→ Native View updates

Communication flow:

  1. JS calls NativeModules.MyModule.doSomething(arg)
  2. Call is serialized to JSON: { module: "MyModule", method: "doSomething", args: [arg] }
  3. Message queued and batched (processed every ~16ms frame)
  4. Native side deserializes and executes on Native Module thread
  5. Result serialized back and queued to JS thread
  6. Promise resolves

Key limitations:

Limitation Impact
Async-only Cannot synchronously read native values
JSON serialization Overhead on every call, no binary data
Single queue All modules share one pipeline — head-of-line blocking
No type safety Runtime errors from typos in method names
Eager loading All native modules loaded at startup
Memory isolation Cannot share object references

These limitations motivated the New Architecture (JSI, Fabric, TurboModules).


Q2: What is JSI, and how does it differ from the old bridge?

Answer:

JSI (JavaScript Interface) is a C++ layer that exposes native objects directly to JavaScript:

Old Bridge:

// Async, serialized, queued
const level = await NativeModules.Battery.getLevel();

JSI:

// Sync, direct C++ call, no serialization
const level = global.__BatteryModule.getLevel();
Aspect Old Bridge JSI
Call type Async only Sync and async
Data transfer JSON serialize/deserialize Direct memory access
Object sharing Not possible Shared C++ objects
Startup All modules loaded Lazy loading (TurboModules)
Type safety Runtime Compile-time (Codegen)
Engine Bridge-specific Engine-agnostic (Hermes, JSC, V8)

JSI enables:

  • Fabric — Synchronous layout and rendering
  • TurboModules — Lazy-loaded, typed native modules
  • Shared ownership — C++ objects accessible from both JS and native
  • Host objects — Native objects appear as JS objects

JSI is the foundation, not the feature itself. Fabric and TurboModules are built on top of JSI.


Q3: How do you create a native module for iOS and Android?

Answer:

Step 1 — Define the JS interface:

// For TurboModules: TypeScript spec with Codegen
// For legacy: Document the API
interface BatteryModuleInterface {
  getBatteryLevel(): Promise<number>;
  startMonitoring(): void;
}

Step 2 — iOS implementation:

// Conform to RCTBridgeModule
@implementation BatteryModule
RCT_EXPORT_MODULE();  // Exposes to JS as "BatteryModule"
RCT_EXPORT_METHOD(getBatteryLevel:(RCTPromiseResolveBlock)resolve
                  rejecter:(RCTPromiseRejectBlock)reject) { ... }
@end

Step 3 — Android implementation:

class BatteryModule(context: ReactApplicationContext) :
    ReactContextBaseJavaModule(context) {
    override fun getName() = "BatteryModule"
    @ReactMethod
    fun getBatteryLevel(promise: Promise) { ... }
}

Step 4 — Register the module:

  • iOS: Create a podspec or add to bridging header
  • Android: Create a ReactPackage and add to MainApplication

Step 5 — Use from JavaScript:

import { NativeModules } from 'react-native';
const { BatteryModule } = NativeModules;

For TurboModules (New Architecture):

  1. Write TypeScript spec in specs/NativeModule.ts
  2. Run Codegen — generates C++ JSI bindings + native stubs
  3. Implement generated native abstract classes
  4. Register in MainApplication / AppDelegate

Q4: How do native modules communicate events back to JavaScript?

Answer:

Native-to-JS communication uses the Event Emitter pattern:

iOS — Extend RCTEventEmitter:

@interface MyModule : RCTEventEmitter <RCTBridgeModule>
@end

@implementation MyModule
- (NSArray<NSString *> *)supportedEvents {
  return @[@"onProgress", @"onComplete"];
}

- (void)sendProgress:(double)progress {
  [self sendEventWithName:@"onProgress" body:@{@"progress": @(progress)}];
}
@end

Android — Use DeviceEventManagerModule:

reactContext
    .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
    .emit("onProgress", Arguments.createMap().apply {
        putDouble("progress", progress)
    })

JavaScript — Subscribe with NativeEventEmitter:

import { NativeEventEmitter, NativeModules } from 'react-native';

const emitter = new NativeEventEmitter(NativeModules.MyModule);

const subscription = emitter.addListener('onProgress', (event) => {
  console.log(`Progress: ${event.progress}%`);
});

// Cleanup
subscription.remove();

Important rules:

  • Always remove listeners on unmount to prevent memory leaks
  • Events are fire-and-forget — no acknowledgment from JS
  • For request-response patterns, use Promises (JS → native) instead
  • TurboModules require addListener/removeListeners methods in spec for Codegen

Q5: When should you write a native module vs using an existing library?

Answer:

Use an existing library when:

  • Popular, maintained library exists (e.g., react-native-camera, react-native-biometrics)
  • Library covers 80%+ of your requirements
  • Community support and documentation available
  • Library supports New Architecture (Fabric/TurboModules)

Write a custom native module when:

  • No library exists for the specific SDK/API
  • Existing library is unmaintained or incompatible with your RN version
  • Performance requirements exceed what JS libraries provide
  • Proprietary/third-party SDK with no React Native wrapper
  • Need fine-grained control over native behavior

Decision matrix:

Factor Use Library Write Native
SDK availability Wrapper exists No wrapper
Performance needs Adequate Critical (ML, video, crypto)
Maintenance capacity Low Team has native devs
Customization Standard features Highly custom
Timeline Fast delivery Long-term investment

Before writing native code, check:

  1. npm registry for existing modules
  2. Expo modules (if using Expo)
  3. Community modules on GitHub
  4. Whether a Web API alternative exists (WebView bridge)

Q6: What are common pitfalls when writing native modules?

Answer:

1. Threading violations:

// Bad — updating UI from background thread
@ReactMethod
fun fetchData(promise: Promise) {
    Thread { 
        val result = heavyComputation()
        updateUI(result)  // CRASH — not on main thread
        promise.resolve(result)
    }.start()
}

Always dispatch UI work to the main thread.

2. Memory leaks from event listeners:

// Bad — never removed
useEffect(() => {
    emitter.addListener('event', handler);
    // Missing cleanup!
}, []);

3. Blocking the JS thread with sync bridge calls: Legacy bridge calls are async, but if you make many rapid calls, the queue backs up and UI stutters.

4. Not handling app lifecycle: Native modules holding resources (Bluetooth, GPS, camera) must clean up on app background/destroy.

5. Missing null checks on Android:

// Bad — activity can be null
reactContext.currentActivity!!.startActivity(intent)

// Good
reactContext.currentActivity?.startActivity(intent)
    ?: promise.reject("NO_ACTIVITY", "Activity is null")

6. Inconsistent module names across platforms: iOS RCT_EXPORT_MODULE(BatteryModule) must match Android getName() = "BatteryModule".

7. Not using Promises correctly: Always resolve OR reject — never leave promises hanging.

8. Forgetting New Architecture compatibility: Test with both old and new architecture enabled during migration.


Q7: How do you debug native modules in React Native?

Answer:

iOS debugging:

  • Xcode debugger — Set breakpoints in .m/.swift files, run from Xcode
  • NSLog / os_log — Native logging visible in Xcode console
  • React Native DevTools — View native module registration
  • Flipper — Inspect native layout, network, databases
RCTLogInfo(@"Battery level: %f", level);  // Visible in Metro + Xcode

Android debugging:

  • Android Studio debugger — Breakpoints in .kt/.java files
  • Logcat — Filter by tag: adb logcat -s ReactNativeJS:B BatteryModule:D
  • Flipper — Native layout inspector, network, shared preferences
Log.d("BatteryModule", "Level: $level")

JavaScript side debugging:

console.log('Available modules:', Object.keys(NativeModules));
// Verify module is registered: should include 'BatteryModule'

Common debugging steps:

  1. Verify module appears in NativeModules object
  2. Check Metro bundler for import errors
  3. Rebuild native app after native code changes (npx react-native run-ios)
  4. Check platform-specific logs (Xcode console / Logcat)
  5. Use try/catch on JS side to capture native rejections
  6. Test on both platforms independently — implementations differ

Hot reload does NOT apply to native code — always rebuild after native changes.


Best Practices Summary

Practice Reason
Prefer TurboModules for new modules Type-safe, lazy-loaded, JSI-powered
Always clean up event listeners Prevent memory leaks
Use Promises for JS → native calls Proper async error handling
Use events for native → JS streaming Sensor data, progress updates
Dispatch UI work to main thread Prevent crashes on both platforms
Match module names across iOS/Android Consistent JS API
Test with New Architecture enabled Future-proof your modules
Rebuild after native changes Hot reload doesn't apply to native code

Navigation: ← Data Persistence | New Architecture →