Skip to main content

Route 3: Build your own TurboModule

Choose this route when JavaScript needs an API your team controls: for example, to expose a native SDK operation outside the supplied library's API or combine Link Mobile with your own native services. Your team owns the TypeScript contract, codegen, Swift/Objective-C++ and Kotlin adapters, event payloads, errors, and lifecycle behavior.

Route 1 is recommended for the easiest integration. Manual native project configuration is already supported by Route 1 Option B; that alone is not a reason to build a custom bridge.

Route 3 starts with Route 2's native SDK installation, then adds JavaScript access. The example below defines an independent application module named ExampleLink, using only the documented native SDK APIs and the example sessions on the Route 2 page.

Scope of this example​

This starter exposes one startup method and one discovery event on both platforms:

Your JavaScript → ExampleLink TurboModule → ExampleLinkSession → native Link Mobile SDK
Your JavaScript ← onDeviceDiscovered event ← native discovery callback

It does not provide the supplied library's credential, DFU, or Proxy-state API. Implement those separately if required. Startup acceptance and discovery do not prove attestation succeeded or a credential exchange granted access.

Complete the app allowlisting. Enable the React Native New Architecture and use the React Native TurboModule guide for your app version's codegen and registration setup. The typed event guide explains the event-emitter pattern used here.

1. Define your own JavaScript contract​

Create this specification in the application's specs/ directory. Your app owns this contract and both native implementations.

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

export type DiscoveredLock = {
identifier: string;
rssi: number;
};

export interface Spec extends TurboModule {
start(): Promise<void>;
readonly onDeviceDiscovered: CodegenTypes.EventEmitter<DiscoveredLock>;
}

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

Merge this codegen configuration into the app's existing package.json:

{
"codegenConfig": {
"name": "ExampleLinkSpec",
"type": "modules",
"jsSrcsDir": "specs",
"android": {
"javaPackageName": "com.example.link"
},
"ios": {
"modulesProvider": {
"ExampleLink": "ExampleLink"
}
}
}
}

Preserve or combine any existing codegen declarations. Generate interfaces before implementing the native adapters. With the app's React Native Gradle plugin configured, run ./gradlew generateCodegenArtifactsFromSchema from android/; run pod install from ios/ for iOS codegen. Expect generated interfaces named NativeExampleLinkSpec and an iOS header under ExampleLinkSpec.

The module lookup, native registration, and modulesProvider mapping must all use ExampleLink. Keep custom names separate from LinkMobile so you do not register a second implementation of the supplied module.

2. Android: implement the adapter​

Complete Route 2's Android setup, including Bridge.init in the existing MainApplication. Add its ExampleLinkSession.kt to com.example.link in the app module. That session owns SDK discovery; this adapter forwards its callback to the generated JavaScript event emitter.

// android/app/src/main/java/com/example/link/ExampleLinkModule.kt
package com.example.link

import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.common.LifecycleState

class ExampleLinkModule(context: ReactApplicationContext) :
NativeExampleLinkSpec(context) {
companion object { const val NAME = "ExampleLink" }
override fun getName() = NAME

private var disposed = false
private val discoveryCallback: (String, Int) -> Unit = { identifier, rssi ->
if (!disposed) {
val payload = Arguments.createMap().apply {
putString("identifier", identifier)
putDouble("rssi", rssi.toDouble())
}
emitOnDeviceDiscovered(payload)
}
}

override fun start(promise: Promise) {
UiThreadUtil.runOnUiThread {
try {
check(!disposed) { "The native module was disposed." }
check(reactApplicationContext.lifecycleState == LifecycleState.RESUMED) {
"Start Link Mobile while the app is visible."
}
ExampleLinkSession.onDiscovery = discoveryCallback
ExampleLinkSession.startFromForeground(reactApplicationContext)
promise.resolve(null)
} catch (error: Exception) {
promise.reject("START_FAILED", error.message, error)
}
}
}

override fun invalidate() {
UiThreadUtil.runOnUiThread {
disposed = true
if (ExampleLinkSession.onDiscovery === discoveryCallback) {
ExampleLinkSession.onDiscovery = null
}
}
super.invalidate()
}
}

Register the module with a package:

// ExampleLinkPackage.kt — same directory and package as the module.
package com.example.link

import com.facebook.react.BaseReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider

class ExampleLinkPackage : BaseReactPackage() {
override fun getModule(name: String, context: ReactApplicationContext): NativeModule? =
if (name == ExampleLinkModule.NAME) ExampleLinkModule(context) else null

override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
mapOf(ExampleLinkModule.NAME to ReactModuleInfo(
ExampleLinkModule.NAME,
ExampleLinkModule::class.java.name,
false, false, false, true,
))
}
}

In MainApplication, add com.example.link.ExampleLinkPackage() to the existing React Native package list once, preserving the autolinked packages and host setup. These examples are app-local; if you instead package your bridge as a library, configure its autolinking and codegen as a library rather than also registering it manually.

3. iOS: implement the adapter​

Complete Route 2's iOS setup and add its ExampleLinkSession.swift to the same app target as this adapter. That Swift session owns the native SDK and discovery callback. The Objective-C++ adapter implements the generated contract and forwards events to JavaScript.

Create ExampleLink.mm and include it in Build Phases → Compile Sources. Replace YourApp-Swift.h with the generated Swift header for your app target's product module name. The .mm extension is required for the C++ TurboModule interfaces.

// ExampleLink.mm
#import <ExampleLinkSpec/ExampleLinkSpec.h>
#import <React/RCTInvalidating.h>
#import <React/RCTUtils.h>
@import LinkMobile;
#import "YourApp-Swift.h"

@interface ExampleLink : NativeExampleLinkSpecBase <NativeExampleLinkSpec, RCTInvalidating>
@end

@implementation ExampleLink {
ExampleLinkSession *_session;
void (^_discoveryHandler)(NSString *, int32_t);
BOOL _disposed;
}

RCT_EXPORT_MODULE(ExampleLink)

+ (BOOL)requiresMainQueueSetup { return YES; }

- (instancetype)init
{
if ((self = [super init])) {
_session = [ExampleLinkSession shared];
__weak ExampleLink *weakSelf = self;
_discoveryHandler = [^(NSString *identifier, int32_t rssi) {
ExampleLink *owner = weakSelf;
if (owner && !owner->_disposed) {
[owner emitOnDeviceDiscovered:@{
@"identifier": identifier, @"rssi": @(rssi)
}];
}
} copy];
}
return self;
}

- (void)start:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject
{
RCTExecuteOnMainQueue(^{
if (self->_disposed) {
reject(@"START_FAILED", @"The native module was disposed.", nil);
return;
}
self->_session.onDiscovery = self->_discoveryHandler;
NSError *error = nil;
if ([self->_session startAndReturnError:&error]) {
resolve(nil);
} else {
reject(@"START_FAILED", error.localizedDescription, error);
}
});
}

- (void)invalidate
{
RCTExecuteOnMainQueue(^{
self->_disposed = YES;
if (self->_session.onDiscovery == self->_discoveryHandler) {
self->_session.onDiscovery = nil;
}
});
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
(const facebook::react::ObjCTurboModule::InitParams &)params
{
return std::make_shared<facebook::react::NativeExampleLinkSpecJSI>(params);
}
@end

Swift exports the example's throwing start() method as startAndReturnError:. Confirm this declaration in your generated Swift header. The app's codegen configuration maps ExampleLink to this Objective-C++ class. Run pod install and rebuild; installing the SDK binaries alone does not generate or compile this adapter.

4. Call your module from JavaScript​

Subscribe before starting. This example requests Android runtime permissions; the corresponding manifest declarations still come from native setup. On Android, turn on Bluetooth and meet the native guide's location-service requirements before starting. On iOS, the native SDK uses the Bluetooth usage descriptions and waits for the radio readiness callback.

import {AppState, PermissionsAndroid, Platform} from 'react-native';
import ExampleLink from './specs/NativeExampleLink';

const discovery = ExampleLink.onDeviceDiscovered((lock) => {
console.log('Custom bridge discovered:', lock.identifier, lock.rssi);
// Update your React state, keyed by identifier.
});

export async function startExampleLink() {
if (AppState.currentState !== 'active') {
throw new Error('Start Link Mobile while the app is visible.');
}
if (Platform.OS === 'android') {
const permissions = Number(Platform.Version) >= 31
? [PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT]
: [PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION];
const grants = await PermissionsAndroid.requestMultiple(permissions);
if (permissions.some((permission) =>
grants[permission] !== PermissionsAndroid.RESULTS.GRANTED)) {
throw new Error('Bluetooth permissions were denied.');
}
}
await ExampleLink.start();
}

// In the application's foreground start action:
// void startExampleLink().catch((error) => showErrorInYourUI(error));
// showErrorInYourUI is an application helper you implement.

// In the subscription owner's cleanup:
export function removeExampleLinkListener() {
discovery.remove();
}

Mount one application-level owner for this subscription and startup flow. The Android adapter uses Bridge.start(), which posts a foreground-service notification and can continue BLE and Proxy work while the app is in the background. Start it while the app is visible; returning to the foreground does not require starting it again. Removing the JavaScript listener or invalidating the adapter detaches JavaScript callbacks; the example native sessions retain ownership of the SDK for the app lifetime. Define a separate native shutdown policy if your application needs to stop SDK work.

Extending and maintaining your bridge​

For each additional feature, define its TypeScript methods and result events, regenerate interfaces, and implement both platforms. Use public native API references matching the SDK versions you distribute. Keep blocking work off the main thread and settle promises once; forward final credential/DFU outcomes through events rather than treating a queued request as success.

Keep native BluetoothDevice instances in a synchronized lookup keyed by their advertised identifier when adding device operations. Send plain serializable values across the JavaScript boundary, and resolve identifiers through that lookup for later calls. Detach module-owned listeners, cancel coroutines/tasks, and close watches when the module is invalidated. The example's single callback slot assumes one application-level consumer; add a subscription manager if several native or JavaScript owners need events.

Rebuild after changing native code or the codegen contract. Verify module resolution, permission denial and retry, discovery events, foreground transitions, and attested Proxy connectivity on physical devices. Test credentials separately after adding them. These starter snippets are integration examples, not a replacement for the full native guides or a guarantee of compatibility with every React Native release.