Home / Articles / Wiring an In-App Turbo Module End to End with React Native Codegen

This article is published in English.

Wiring an In-App Turbo Module End to End with React Native Codegen

Define a typed spec, run codegen, and implement a Turbo Module on iOS and Android with synchronous, Promise, callback and event-emitter methods.

1409 words

React Native's New Architecture replaces the asynchronous bridge with Turbo Modules, which JavaScript calls through JSI with typed, generated bindings. The result is lower call overhead and a contract checked by codegen rather than by convention. This walkthrough builds one module inside an app (no npm package) and covers the three call styles you will use in practice: a synchronous return value, asynchronous results via a Promise or callback, and a stream of events.

Writing the typed spec

Create a specs folder at the project root to hold module schemas:

/your-app
  /specs

Inside it, add NativeShowCase.ts. Codegen only picks up spec files whose names begin with Native. The spec below declares one method per style: haveCameraFlash returns synchronously, toggleFlashLight returns a Promise, fetchUser takes a callback, and evenListenerCalled is an event emitter carrying key/value pairs. getEnforcing throws at startup if the native side is missing, which surfaces wiring mistakes early. Typed event emitters in specs are a recent addition, so confirm your React Native version supports CodegenTypes.EventEmitter.

import type { CodegenTypes, TurboModule } from "react-native";
import { TurboModuleRegistry } from "react-native";

export type KeyValuePair = {
  key: string;
  value: string;
};

export interface Spec extends TurboModule {
  toggleFlashLight(): Promise<boolean>;
  haveCameraFlash(): boolean;
  fetchUser(
    id: string,
    callback: (user: { id: string; name: string }) => void
  ): void;
  readonly evenListenerCalled: CodegenTypes.EventEmitter<KeyValuePair>;
}

export default TurboModuleRegistry.getEnforcing<Spec>("NativeShowCase");

This file is the single source of truth; both platforms' bindings are generated from it.

Configuring and running codegen

Add a codegenConfig block to package.json. jsSrcsDir must match your specs folder, and javaPackageName decides where the Android classes are generated:

"codegenConfig": {
  "name": "NativeShowCase",
  "type": "modules",
  "jsSrcsDir": "specs",
  "android": {
    "javaPackageName": "com.nativeshowcase"
  }
}

Then generate the artifacts. On Android, run the Gradle task:

cd android && ./gradlew generateCodegenArtifactsFromSchema

On iOS, codegen runs as part of installing pods:

cd ios && pod install

You now have generated interfaces, headers and glue code for both platforms.

Implementing the module on iOS

Open the .xcworkspace in Xcode, create a NativeShowCase group, and add an Objective-C class named RCTNativeShowCase. Rename the .m file to .mm: Turbo Modules need Objective-C++ to interoperate with the C++ JSI layer.

The header imports the generated module and inherits from the generated base class while adopting the generated protocol:

#import <Foundation/Foundation.h>
#import <NativeShowCase/NativeShowCase.h>
NS_ASSUME_NONNULL_BEGIN

@interface RCTNativeShowCase : NativeShowCaseSpecBase<NativeShowCaseSpec>

@end

NS_ASSUME_NONNULL_END

The implementation returns the module name, hands React Native a JSI object from getTurboModule, and implements each spec method. toggleFlashLight resolves the Promise, fetchUser invokes the callback, and haveCameraFlash returns immediately but starts a timer that emits ten events through the generated emitEvenListenerCalled method.

#import "RCTNativeShowCase.h"

@interface RCTNativeShowCase()
@property (nonatomic, strong) NSTimer *myTimer;
@property (nonatomic, assign) int count;
@end

@implementation RCTNativeShowCase

+ (NSString *)moduleName {
  return @"NativeShowCase";
}

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

- (void)toggleFlashLight:(nonnull RCTPromiseResolveBlock)resolve reject:(nonnull RCTPromiseRejectBlock)reject {
  resolve(@(false));
}

- (nonnull NSNumber *)haveCameraFlash {
  [self startTimer];
  return [NSNumber numberWithBool:true];
}

- (void)fetchUser:(nonnull NSString *)userId callback:(nonnull RCTResponseSenderBlock)callback {
  callback(@[@{@"key": userId, @"value": @"John"}]);
}


-(void)startTimer {
  if(self.myTimer) {
    [self stopTimer];
  }
  self.count = 0;
  self.myTimer = [NSTimer scheduledTimerWithTimeInterval:1.0
                                                  target:self
                                                selector:@selector(updateCount)
                                                userInfo:nil
                                                 repeats:YES];
}
- (void)updateCount {
  self.count += 1;
  [self emitEvenListenerCalled:@{@"key": @"count", @"value": @(_count)}];
  if (self.count == 10) {
    [self stopTimer];
  }
}
- (void)stopTimer {
  [self.myTimer invalidate];
  self.myTimer = nil;
}
@end

Note that this iOS fetchUser sends key/value fields, while the spec promises id and name; align them in real code, because codegen does not validate callback payload shapes at runtime.

Implementing the module on Android

Create the com.nativeshowcase package declared in codegenConfig. The module class extends the generated NativeShowCaseSpec and overrides every method: it toggles a flag and resolves the Promise, runs a CountDownTimer that emits the remaining seconds each tick, and builds a map for the callback.

package com.nativeshowcase

import android.os.CountDownTimer
import android.widget.Toast
import com.facebook.fbreact.specs.NativeShowCaseSpec
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Callback
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext

class NativeShowCaseModule(reactContext: ReactApplicationContext) :
    NativeShowCaseSpec(reactContext) {
    var timer: CountDownTimer? = null
    var lightOn = false
    override fun toggleFlashLight(promise: Promise?) {
        Toast.makeText(reactApplicationContext, "Let's turn on flash light", Toast.LENGTH_LONG)
            .show()
        lightOn = !lightOn
        promise?.resolve(lightOn)
    }

    override fun haveCameraFlash(): Boolean {
        if (timer != null) {
            timer?.cancel()
            timer = null
        }
        timer = object : CountDownTimer(10000, 1000) {
            override fun onTick(millisUntilFinished: Long) {
                val eventData = Arguments.createMap().apply {
                    putString("key", "count")
                    putInt("value", (millisUntilFinished / 1000).toInt())
                }
                emitEvenListenerCalled(eventData)
            }

            override fun onFinish() {
                timer?.cancel()
                timer = null
            }
        }
        timer?.start()
        return true
    }

    override fun fetchUser(id: String?, callback: Callback?) {
        val eventData = Arguments.createMap().apply {
            putString("id", id)
            putString("name", "John")
        }
        callback.let { it?.invoke(eventData) }
    }

    companion object {
        const val NAME = "NativeShowCase"
    }

    override fun getName() = NAME
}

A package class built on BaseReactPackage tells React Native how to instantiate the module. The flag isTurboModule = true in its ReactModuleInfo is what routes it through the new system.

package com.nativeshowcase

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 NativeShowCasePackage : BaseReactPackage() {
    override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
        if (name == NativeShowCaseModule.NAME) return NativeShowCaseModule(reactContext)
        return null
    }

    override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
        mapOf(
            NativeShowCaseModule.NAME to ReactModuleInfo(
                name = NativeShowCaseModule.NAME,
                className = NativeShowCaseModule.NAME,
                canOverrideExistingModule = false,
                needsEagerInit = true,
                isCxxModule = false,
                isTurboModule = true
            )
        )
    }
}

Finally, register the package in MainApplication.kt by adding it to the autolinked package list:

class MainApplication : Application(), ReactApplication {
    override val reactHost: ReactHost by lazy {
        getDefaultReactHost(
            context = applicationContext,
            packageList =
                PackageList(this).packages.apply {
                    add(NativeShowCasePackage())
                },
        )
    }
    override fun onCreate() {
        super.onCreate()
        loadReactNative(this)
    }
}

Calling the module from React

Import the default export of the spec and call it like any object. The effect subscribes to the event emitter and returns a cleanup that removes the listener on unmount; one button calls the synchronous method, the other exercises the Promise and callback methods.

import { StatusBar, StyleSheet, useColorScheme, View, Button } from 'react-native';
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context';
import NativeShowCase from './specs/NativeShowCase';
import { useEffect } from 'react';

function App() {
  const isDarkMode = useColorScheme() === 'dark';
  return (
    <SafeAreaProvider>
      <StatusBar barStyle={isDarkMode ? 'light-content' : 'dark-content'} />
      <AppContent />
    </SafeAreaProvider>
  );
}
function AppContent() {
  useEffect(() => {
    const listener = NativeShowCase.evenListenerCalled(pair => {
      console.log('Event received:', pair);
    });
    return () => listener.remove();
  }, []);
  return (
    <View style={styles.container}>
      <Button
        title="Have Camera Flash?"
        onPress={() => {
          console.log('Have Camera Flash:', NativeShowCase.haveCameraFlash());
        }}
      />
      <Button
        title="Toggle Flashlight"
        onPress={() => {
          NativeShowCase.toggleFlashLight()
            .then(isOn => console.log('Flashlight:', isOn))
            .catch(err => console.error(err));
          NativeShowCase.fetchUser('123', user => {
            console.log('Fetched user:', user);
          });
        }}
      />
    </View>
  );
}
const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
  },
});
export default App;

Use synchronous methods sparingly: they block the JavaScript thread until native returns, so reserve them for cheap lookups. For an alternative binding approach, see how Nitro Modules compare with Turbo Modules.

Key takeaways

  • The TypeScript spec is the contract; codegen derives native interfaces for both platforms from it.
  • Name spec files with the Native prefix and keep codegenConfig in sync with folders and packages.
  • iOS implementations need .mm files; Android packages must mark modules with isTurboModule.
  • Pick the call style deliberately: sync for trivial reads, Promises or callbacks for work, emitters for streams.