Native Modules and Components

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

React Native Architecture Under the Hood introduced Turbo Native Modules and Codegen as architecture: a native capability exposed to JavaScript as a typed JSI host object, generated from a single TypeScript declaration. This page is the practical counterpart — how to decide that Places genuinely needs native code at all, and then how to write it end to end on both platforms: the spec, the codegenConfig entry, the generated interfaces, the Kotlin and Swift/Objective-C++ implementations, a Fabric Native Component when the capability is a view rather than a function, how to ship any of it as a reusable library, and the Expo Modules API as the framework-native alternative that replaces most of that machinery with a Swift/Kotlin DSL. Everything here assumes React Native 0.87 on the New Architecture, where Turbo Native Modules and Fabric are the only supported way to write new native code.

When Native Code Is Necessary

Writing native code is the most expensive option available to a React Native app: it doubles the implementation (Kotlin and Swift), takes the capability out of over-the-air update range, and means anyone building the project needs a working Android and Xcode toolchain. It is worth reaching for only after the cheaper options have actually been ruled out, in roughly this order:

Option When it is the right answer

A Core Component or built-in API

The capability already ships with React Native — Vibration, Linking, Share, AppState and the rest of the surface covered on Platform-Specific Code and Device APIs. Nothing to write.

An Expo SDK module

Expo SDK 57 already wraps the capability (expo-haptics, expo-location, expo-camera, expo-secure-store). This is the default answer for anything a phone can obviously do; see Device Capabilities.

A community library

A maintained package on the React Native Directory exposes it, already migrated to the New Architecture. The cost is a dependency rather than a native codebase.

Platform-specific JavaScript

The difference is behavioural, not capability-level — Platform.select, a .ios.ts/.android.ts file pair. No native code is involved at all.

Your own native module

The capability exists in a platform SDK (or a vendor SDK, or an existing in-house native library) that nothing on the JavaScript side exposes, or the work must run natively for performance or threading reasons. This is the case this page covers.

Places reaches the last row exactly once. Its favourite toggle wants a specific, short haptic impact whose style the app chooses per interaction — something Vibration.vibrate() cannot express (it only knows durations) and that expo-haptics exposes only through its own fixed enumeration. That is the whole justification for HapticTap: a single method, three styles, mapped onto iOS’s UIImpactFeedbackGenerator and Android’s haptic feedback constants. A native module that small is the ideal shape — a thin, typed surface over one platform API, with no business logic on the native side of the boundary.

The Turbo Native Module Workflow

A Turbo Native Module is declared once, in TypeScript, and everything native is generated from or checked against that declaration. The workflow has five steps, and the only hand-written artefacts are the spec at the start and the platform implementations at the end:

flowchart TD A["src/native/HapticTap.ts
TypeScript spec: interface Spec extends TurboModule"] --> B["codegenConfig in package.json
name, type: 'modules', jsSrcsDir, android.javaPackageName"] B --> C["Codegen runs at build time
(Gradle task on Android, script phase on iOS)"] C --> D["Generated Kotlin/Java
abstract class NativeHapticTapSpec"] C --> E["Generated Objective-C++
protocol NativeHapticTapSpec"] D --> F["HapticTapModule.kt implements it,
registered by HapticTapPackage"] E --> G["HapticTap.swift + HapticTap.mm conform to it,
registered as an RCTModuleProvider"] F --> H["TurboModuleRegistry.getEnforcing<Spec>('HapticTap')
resolves a JSI host object at first use"] G --> H H --> I["PlaceCard's favourite toggle calls tap('light')"]

Step 1: The TypeScript Spec

The spec is an ordinary TypeScript file that exports an interface extending TurboModule and, as its default export, the result of looking that module up in the registry. It is the single source of truth: Codegen parses it, TypeScript type-checks every call site against it, and the native implementations are forced to match it.

// src/native/HapticTap.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export type HapticStyle = 'light' | 'medium' | 'heavy';

export interface Spec extends TurboModule {
  tap(style: HapticStyle): void;
}

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

Three details carry more weight than their size suggests. The interface must be named Spec and the file must be named Native<Something>.ts or be pointed at explicitly by jsSrcsDir — Codegen discovers specs by convention, and a file it does not recognise silently generates nothing. getEnforcing<Spec> throws at first access if the native side never registered a module under that name, which turns a missing or misconfigured native implementation into an immediate, named error instead of an undefined method call somewhere deep in a component; TurboModuleRegistry.get<Spec> is the nullable variant, for a module that is genuinely optional on one platform. And the types Codegen accepts are a deliberately restricted subset — boolean, number, string, string-literal unions, arrays, object literals, Promise<T>, and callbacks — because every one of them has to map onto something expressible in Kotlin and Objective-C++. A TypeScript type Codegen cannot map fails the build rather than degrading at runtime.

Note also that the spec imports TurboModule and TurboModuleRegistry from the package root — react-native — not from a deep path inside it. Deep imports are not part of the Strict TypeScript API that React Native 0.87 publishes, and code relying on them can break on any minor upgrade; see TypeScript in React Native.

Step 2: codegenConfig in package.json

Codegen is driven by a codegenConfig block in the project’s (or library’s) package.json. It tells the build where the specs live and what to generate from them:

{
  "name": "places",
  "version": "1.0.0",
  "codegenConfig": {
    "name": "PlacesSpecs",
    "type": "modules",
    "jsSrcsDir": "src/native",
    "android": {
      "javaPackageName": "com.irurueta.places.nativemodules"
    }
  }
}

name becomes the name of the generated native library (the Gradle module and the iOS pod target); type is modules for Turbo Native Modules, components for Fabric Native Components, or all when a package ships both; jsSrcsDir is the directory Codegen scans for specs, relative to the package.json; and android.javaPackageName is the package the generated Java/Kotlin lands in. Codegen then runs automatically as part of each platform’s build — a Gradle task on Android, a script build phase (installed by pod install) on iOS — so there is no separate generation command to remember in normal development. npx react-native codegen runs it on demand when you want to inspect the output before building.

Step 3: What Codegen Produces

Codegen never writes into your source tree. Its output lands in the build directories — android/app/build/generated/source/codegen/ and ios/build/generated/ios/ — and is regenerated from the spec on every build, which is exactly why the JavaScript-facing types and the native signatures cannot drift apart. For HapticTap it produces:

  • On Android, an abstract class NativeHapticTapSpec extending ReactContextBaseJavaModule and implementing TurboModule, with an abstract tap(String style) your module must override, plus the JSI binding glue in C++ that dispatches a JavaScript call to it.

  • On iOS, an Objective-C protocol `NativeHapticTapSpec` inheriting from `RCTBridgeModule` and `RCTTurboModule` with a `- (void)tap:(NSString *)style` requirement, plus the matching C TurboModule subclass that routes JSI invocations to whatever conforms to that protocol.

Both are compile-time contracts. Rename tap in the spec without touching the native side and the Android build fails on an unimplemented abstract member while the iOS build fails on an unsatisfied protocol requirement — which is the entire point of the exercise.

Step 4: The Android Implementation (Kotlin)

The Kotlin module extends the generated spec class and does the actual work. getName() must return the exact string the JavaScript side passes to getEnforcing — that string is the registry key:

// android/app/src/main/java/com/irurueta/places/nativemodules/HapticTapModule.kt
package com.irurueta.places.nativemodules

import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.annotations.ReactModule
import com.irurueta.places.nativemodules.NativeHapticTapSpec

@ReactModule(name = HapticTapModule.NAME)
class HapticTapModule(reactContext: ReactApplicationContext) : NativeHapticTapSpec(reactContext) {

    override fun getName(): String = NAME

    override fun tap(style: String) {
        val amplitude = when (style) {
            "light" -> 60
            "medium" -> 140
            "heavy" -> 255
            else -> 140
        }
        val durationMs = if (style == "heavy") 25L else 12L
        vibrator()?.vibrate(VibrationEffect.createOneShot(durationMs, amplitude))
    }

    private fun vibrator(): Vibrator? {
        val context = reactApplicationContext
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            val manager =
                context.getSystemService(VibratorManager::class.java) ?: return null
            manager.defaultVibrator
        } else {
            context.getSystemService(Vibrator::class.java)
        }
    }

    companion object {
        const val NAME = "HapticTap"
    }
}

A module is only reachable once a ReactPackage advertises it. Under the New Architecture the package implements BaseReactPackage (or TurboReactPackage), which is what makes loading lazy: the registry asks getModule(name, context) for a module only when JavaScript first resolves that name, and getReactModuleInfoProvider() describes what the package can supply without instantiating anything.

// android/app/src/main/java/com/irurueta/places/nativemodules/HapticTapPackage.kt
package com.irurueta.places.nativemodules

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 HapticTapPackage : BaseReactPackage() {

    override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
        if (name == HapticTapModule.NAME) HapticTapModule(reactContext) else null

    override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider {
        mapOf(
            HapticTapModule.NAME to ReactModuleInfo(
                HapticTapModule.NAME,
                HapticTapModule.NAME,
                false, // canOverrideExistingModule
                false, // needsEagerInit
                false, // isCxxModule
                true   // isTurboModule
            )
        )
    }
}

The package is then added to the host’s package list in MainApplication.kt (alongside the autolinked ones), and the Kotlin language features used above — when expressions, object companions, nullable types — are covered in Kotlin for Android.

Step 5: The iOS Implementation (Swift and Objective-C++)

iOS needs two files, because Codegen emits an Objective-C protocol and Swift cannot conform to an Objective-C protocol directly. The idiomatic split is to write the real logic in Swift and keep a thin Objective-C file that conforms to the generated protocol and forwards into it. For how Swift, Objective-C and Objective-C coexist in one Xcode target in general — bridging headers, the generated -Swift.h header and @objc — see Mixing Swift, Objective-C and C++ in an App.

// ios/Places/HapticTap.swift
import UIKit

@objc(HapticTapImpl)
public class HapticTapImpl: NSObject {

  @objc
  public static func tap(_ style: String) {
    let feedbackStyle: UIImpactFeedbackGenerator.FeedbackStyle
    switch style {
    case "light": feedbackStyle = .light
    case "heavy": feedbackStyle = .heavy
    default: feedbackStyle = .medium
    }

    DispatchQueue.main.async {
      let generator = UIImpactFeedbackGenerator(style: feedbackStyle)
      generator.prepare()
      generator.impactOccurred()
    }
  }
}
// ios/Places/HapticTap.mm
#import <Foundation/Foundation.h>
#import <PlacesSpecs/PlacesSpecs.h>
#import "Places-Swift.h"   // generated Swift interop header

@interface HapticTap : NSObject <NativeHapticTapSpec>
@end

@implementation HapticTap

RCT_EXPORT_MODULE()

- (void)tap:(NSString *)style
{
  [HapticTapImpl tap:style];
}

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

@end

RCT_EXPORT_MODULE() with no argument derives the registry name from the class name — HapticTap, matching getEnforcing<Spec>('HapticTap'). getTurboModule: is what hands the runtime the Codegen-generated JSI binding class for this module; every Objective-C Turbo Module implements it, and it is boilerplate you copy rather than write. The `.mm` extension (rather than `.m`) is mandatory: the generated headers are C. The Swift reference and the Objective-C reference cover both languages, including the @objc/NS_SWIFT_NAME interop rules that make the bridging header above work.

On a project using the app template’s RCTAppDelegate, an app-local module in the ios/Places target is picked up automatically once the file is in the Xcode target. A module shipped as a library instead registers itself through the autolinking manifest — covered under packaging, below.

Step 6: Calling It from PlaceCard

On the JavaScript side there is nothing module-specific left to do: the default export of the spec file is the typed module. `PlaceCard’s favourite toggle calls it directly, alongside the state update it already performed:

// src/features/places/components/PlaceCard.tsx (excerpt -- haptic feedback on the favourite toggle)
import { Pressable, Text } from 'react-native';
import HapticTap from '../../../native/HapticTap';
import { useFavouritesStore } from '../store/useFavouritesStore';

// inside PlaceCard, the nested favourite-toggle Pressable:
<Pressable
  onPress={() => {
    HapticTap.tap(place.favourite ? 'light' : 'medium');
    onToggleFavourite(place.id);
  }}
  hitSlop={8}
  accessibilityRole="button"
  accessibilityLabel={place.favourite ? 'Remove from favourites' : 'Add to favourites'}
  accessibilityState={{ selected: place.favourite }}
>
  <Text style={styles.favouriteIcon}>{place.favourite ? '★' : '☆'}</Text>
</Pressable>

HapticTap.tap('light') is fully typed: passing 'soft' is a compile error, because HapticStyle is a string-literal union in the spec. The call is synchronous — it returns as soon as the native method has been invoked, with no promise to await — which is exactly what a JSI-backed void method buys and what the legacy bridge could never offer.

Promises, Events, and Constants

tap() is the simplest possible shape. Three richer shapes cover almost everything else a module needs:

Promises. A spec method typed Promise<T> becomes a method taking a promise pair on the native side — Promise on Android (resolve/reject), RCTPromiseResolveBlock/RCTPromiseRejectBlock on iOS. This is the right shape for anything asynchronous or fallible, because a rejection surfaces in JavaScript as a normal thrown error you can try/catch:

// src/native/HapticTap.ts (a richer spec: capability query, asynchronous because it may hit a system service)
export interface Spec extends TurboModule {
  tap(style: HapticStyle): void;
  isSupported(): Promise<boolean>;
}
override fun isSupported(promise: Promise) {
    try {
        promise.resolve(vibrator()?.hasVibrator() == true)
    } catch (e: Exception) {
        promise.reject("E_HAPTIC_UNAVAILABLE", e)
    }
}

Events. For native-initiated notifications there is no JavaScript callback to hold; the module emits events and JavaScript subscribes. Declare addListener(eventName: string): void and removeListeners(count: number): void in the spec (Codegen requires both for an event-emitting module), emit with emitDeviceEvent from the Kotlin module or emitOnChange-style generated helpers, and subscribe in JavaScript through NativeEventEmitter. Places does not need this for haptics, but it is the shape a location-tracking or download-progress module would take.

Constants. A getConstants() method in the spec returns a value read once, synchronously, at module initialisation — suited to genuinely static facts (a platform SDK version, a device capability flag) and actively unsuitable for anything that can change while the app runs, since JavaScript will keep the first value forever. In practice a Promise-returning getter is the better default, and getConstants() is worth using only when a value must be available synchronously during the first render.

The Turbo Module Registry and the JSI Call Path

What actually happens the first time PlaceCard calls HapticTap.tap('light') is worth following once, because it explains both the lazy-loading behaviour and why the call is synchronous:

The Turbo Module registry and the JSI call path: PlaceCard’s favourite toggle calls HapticTap.tap

The registry (TurboModuleManager) holds a map from module name to provider, not to instantiated modules. Nothing native is constructed at startup: the module is created the first time JavaScript resolves its name, and from then on the JavaScript side holds a JSI host object — a live C++ reference — so subsequent calls skip the registry entirely. That is the whole difference from the legacy bridge, where every registered native module was eagerly initialised during startup whether or not the app ever used it, and every call afterwards was a serialized, asynchronous message.

Cross-Platform C++ Modules

When a capability has no platform-specific dependency at all — a parser, a cryptographic routine, a computation, or an existing C library you want to reuse -- a **C Turbo Native Module** lets you write the implementation once and have both platforms load the same code. The spec is the same TypeScript file; what changes is that Codegen generates a single C++ base class instead of per-platform interfaces, and the ReactModuleInfo above is registered with isCxxModule = true.

// shared/NativePlaceDistance.h
#pragma once
#include <AppSpecsJSI.h>
#include <memory>
#include <string>

namespace facebook::react {

class NativePlaceDistance : public NativePlaceDistanceCxxSpec<NativePlaceDistance> {
 public:
  NativePlaceDistance(std::shared_ptr<CallInvoker> jsInvoker);

  // Great-circle distance in metres between two Places coordinates.
  double distanceBetween(jsi::Runtime &rt, double lat1, double lon1, double lat2, double lon2);
};

} // namespace facebook::react

The same .cpp file is compiled into the Android build through CMake (CMakeLists.txt in android/app/src/main/jni) and into the iOS build as a source file on the app or pod target, with android.cxxModuleHeaderName added to codegenConfig so Codegen knows which header to include. The trade-off is the obvious one: no access to any platform API, a build setup that is harder to debug than Kotlin or Swift, and C++ as the maintenance language — worth it for shared algorithmic code or an existing native library, not for a thin wrapper over a platform SDK the way HapticTap is.

Fabric Native Components

A Turbo Native Module exposes functions. When what you need is a real native view participating in Flexbox layout — a map, a video surface, a platform chart, a camera preview — the counterpart is a Fabric Native Component, declared with codegenNativeComponent and rendered as an ordinary JSX element.

Declaring the Component

The spec is again TypeScript, and again drives Codegen. The prop types come from React Native’s own Codegen-aware type aliases (imported from the package root), because a prop has to be expressible in the shadow tree:

// src/native/PlaceMapViewNativeComponent.ts
import type { ComponentRef } from 'react';
import type { DirectEventHandler, Double, HostComponent, ViewProps } from 'react-native';
import { codegenNativeCommands, codegenNativeComponent } from 'react-native';

type PinPressEvent = Readonly<{ placeId: string }>;

export interface NativeProps extends ViewProps {
  latitude: Double;
  longitude: Double;
  zoom?: Double;
  showsTraffic?: boolean;
  onPinPress?: DirectEventHandler<PinPressEvent>;
}

export type PlaceMapViewType = HostComponent<NativeProps>;

interface NativeCommands {
  animateToPlace: (viewRef: ComponentRef<PlaceMapViewType>, latitude: Double, longitude: Double) => void;
}

export const Commands: NativeCommands = codegenNativeCommands<NativeCommands>({
  supportedCommands: ['animateToPlace'],
});

export default codegenNativeComponent<NativeProps>('PlaceMapView') as PlaceMapViewType;

Two separate mechanisms appear here, and the distinction matters. Props are declarative state: they live in the shadow tree, are diffed on every commit, and flow one way into the view. Commands are imperative, one-shot instructions sent to a specific mounted view through a ref — Commands.animateToPlace(ref.current, lat, lon) — for things that are events rather than state, like starting an animation or scrolling to a position. Modelling "animate the camera" as a prop would mean re-triggering it on every unrelated re-render.

DirectEventHandler<T> declares an event the view bubbles up to the on<Name> prop; the payload type is a Readonly object literal, and on the native side it is emitted as a structured event rather than a callback invocation.

The Android ViewManager

Android implements the component with a ViewManager that Codegen has generated an interface and a delegate for. The manager creates the view, applies each prop, and handles commands:

// android/app/src/main/java/com/irurueta/places/nativecomponents/PlaceMapViewManager.kt
package com.irurueta.places.nativecomponents

import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.SimpleViewManager
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewManagerDelegate
import com.facebook.react.viewmanagers.PlaceMapViewManagerDelegate
import com.facebook.react.viewmanagers.PlaceMapViewManagerInterface

@ReactModule(name = PlaceMapViewManager.NAME)
class PlaceMapViewManager :
    SimpleViewManager<PlaceMapView>(),
    PlaceMapViewManagerInterface<PlaceMapView> {

    private val delegate: ViewManagerDelegate<PlaceMapView> = PlaceMapViewManagerDelegate(this)

    override fun getDelegate(): ViewManagerDelegate<PlaceMapView> = delegate

    override fun getName(): String = NAME

    override fun createViewInstance(context: ThemedReactContext): PlaceMapView = PlaceMapView(context)

    override fun setLatitude(view: PlaceMapView, value: Double) = view.setLatitude(value)

    override fun setLongitude(view: PlaceMapView, value: Double) = view.setLongitude(value)

    override fun setZoom(view: PlaceMapView, value: Double) = view.setZoom(value)

    override fun setShowsTraffic(view: PlaceMapView, value: Boolean) = view.setShowsTraffic(value)

    override fun animateToPlace(view: PlaceMapView, latitude: Double, longitude: Double) =
        view.animateTo(latitude, longitude)

    companion object {
        const val NAME = "PlaceMapView"
    }
}

The manager is registered from the same ReactPackage as any Turbo Module, through createViewManagers().

The iOS RCTViewComponentView

iOS implements the component as a subclass of RCTViewComponentView, the Fabric-era base class that replaced the old RCTViewManager. Props arrive already typed, in a generated C++ props struct, through updateProps:oldProps::

// ios/Places/PlaceMapComponentView.mm
#import "PlaceMapComponentView.h"

#import <react/renderer/components/PlacesSpecs/ComponentDescriptors.h>
#import <react/renderer/components/PlacesSpecs/Props.h>
#import <react/renderer/components/PlacesSpecs/RCTComponentViewHelpers.h>

using namespace facebook::react;

@interface PlaceMapComponentView () <RCTPlaceMapViewViewProtocol>
@end

@implementation PlaceMapComponentView {
  MKMapView *_mapView;
}

+ (ComponentDescriptorProvider)componentDescriptorProvider
{
  return concreteComponentDescriptorProvider<PlaceMapViewComponentDescriptor>();
}

- (instancetype)initWithFrame:(CGRect)frame
{
  if (self = [super initWithFrame:frame]) {
    _mapView = [[MKMapView alloc] initWithFrame:self.bounds];
    self.contentView = _mapView;   // Fabric hosts the native view here
  }
  return self;
}

- (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &)oldProps
{
  const auto &next = *std::static_pointer_cast<const PlaceMapViewProps>(props);
  _mapView.showsTraffic = next.showsTraffic;
  [self centerOnLatitude:next.latitude longitude:next.longitude zoom:next.zoom];
  [super updateProps:props oldProps:oldProps];
}

- (void)animateToPlace:(double)latitude longitude:(double)longitude
{
  [self animateCameraToLatitude:latitude longitude:longitude];
}

@end

Class<RCTComponentViewProtocol> PlaceMapViewCls(void)
{
  return PlaceMapComponentView.class;
}

Comparing next against oldProps before doing expensive work is the usual optimisation: updateProps: is called on every commit that touches this view, not only when a prop you care about actually changed. The trailing PlaceMapViewCls() C function is how Fabric’s component registry discovers the class by name.

Packaging a Module as a Library

Anything written directly inside ios/ and android/ belongs to one app. To reuse a module across projects — or publish it — scaffold it as a library instead of hand-assembling the Gradle module, podspec, and build configuration:

npx create-react-native-library@latest react-native-haptic-tap

The generator asks what kind of package it is — a Turbo Native Module or a Fabric Native Component, in Kotlin and Swift/Objective-C, in C, or built on the Expo Modules API — and emits a complete package: the spec directory, a codegenConfig block, an Android Gradle module, an iOS podspec, a working example app, and a react-native.config.js so autolinking wires the library into any consuming app with no manual registration at all. react-native-builder-bob is the build tool it configures, compiling the library’s TypeScript to CommonJS, ES modules, and type declarations on npm publish so consumers get types without compiling the source themselves.

Two habits make such a library usable by others: declare react-native and react as peerDependencies (never plain dependencies — two copies of React Native in one app is a hard failure), and keep the spec files in the published package rather than only the generated output, since the consuming app’s own build runs Codegen against them.

The Expo Modules API

Expo SDK 57 offers a different path to the same destination. The Expo Modules API is a Swift and Kotlin DSL that replaces the spec-plus-Codegen-plus-boilerplate workflow above with a single declarative definition block per platform: no TypeScript spec, no codegenConfig, no generated abstract classes to conform to, and no ViewManager/RCTViewComponentView boilerplate for views. Types are inferred from the Swift and Kotlin signatures, and the JavaScript side gets the module by name.

Here is `HapticTap’s exact capability written that way. The iOS side:

// modules/haptic-tap/ios/HapticTapModule.swift
import ExpoModulesCore
import UIKit

public class HapticTapModule: Module {
  public func definition() -> ModuleDefinition {
    Name("HapticTap")

    Function("tap") { (style: String) in
      let feedbackStyle: UIImpactFeedbackGenerator.FeedbackStyle
      switch style {
      case "light": feedbackStyle = .light
      case "heavy": feedbackStyle = .heavy
      default: feedbackStyle = .medium
      }
      let generator = UIImpactFeedbackGenerator(style: feedbackStyle)
      generator.prepare()
      generator.impactOccurred()
    }
    .runOnQueue(.main)

    AsyncFunction("isSupported") { () -> Bool in
      true
    }
  }
}

And the Android side:

// modules/haptic-tap/android/src/main/java/com/irurueta/places/haptictap/HapticTapModule.kt
package com.irurueta.places.haptictap

import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition

class HapticTapModule : Module() {
    override fun definition() = ModuleDefinition {
        Name("HapticTap")

        Function("tap") { style: String ->
            val amplitude = when (style) {
                "light" -> 60
                "medium" -> 140
                "heavy" -> 255
                else -> 140
            }
            val durationMs = if (style == "heavy") 25L else 12L
            vibrator()?.vibrate(VibrationEffect.createOneShot(durationMs, amplitude))
        }

        AsyncFunction("isSupported") {
            vibrator()?.hasVibrator() == true
        }
    }

    private fun vibrator(): Vibrator? {
        val context = appContext.reactContext ?: return null
        val manager = context.getSystemService(VibratorManager::class.java) ?: return null
        return manager.defaultVibrator
    }
}

The TypeScript side is a hand-written wrapper rather than a generated one, and it is deliberately small:

// modules/haptic-tap/src/index.ts
import { requireNativeModule } from 'expo-modules-core';

export type HapticStyle = 'light' | 'medium' | 'heavy';

interface HapticTapModule {
  tap(style: HapticStyle): void;
  isSupported(): Promise<boolean>;
}

export default requireNativeModule<HapticTapModule>('HapticTap');

Function runs synchronously on the JS thread through JSI, exactly like a Turbo Module’s void method; AsyncFunction returns a promise and runs off the JS thread. View and Prop/Events components of the same DSL cover native views, playing the role a Fabric Native Component’s ViewManager and RCTViewComponentView play above. npx create-expo-module@latest scaffolds a standalone package, while npx create-expo-module@latest --local creates the module directly inside an app under modules/, which is where the file paths above come from.

The honest comparison is this. The Expo Modules API is dramatically less code for the same result, it is written entirely in Swift and Kotlin rather than partly in Objective-C++, and it handles lifecycle events, permissions, and view components through the same DSL — but it requires expo-modules-core in the app, and its type safety comes from inference and a hand-written TypeScript wrapper rather than from Codegen mechanically deriving both sides from one declaration. A project already on Expo SDK 57, as Places is, has little reason not to use it; a bare React Native app, or a library that must work in apps without Expo, wants the Turbo Native Module path.

Config Plugins

A native module frequently needs something in the app’s native project too — a permission string in Info.plist, an AndroidManifest.xml entry, a Gradle dependency, an entitlement. In a Continuous Native Generation workflow, where ios/ and android/ are generated by npx expo prebuild rather than committed, editing those files by hand is pointless: the next prebuild discards the edit. A config plugin is the supported answer — a JavaScript function that receives the app config and returns a modified one, applied during prebuild:

// modules/haptic-tap/plugin.js
const { withAndroidManifest, AndroidConfig } = require('expo/config-plugins');

module.exports = function withHapticTap(config) {
  return withAndroidManifest(config, (config) => {
    AndroidConfig.Manifest.ensurePermission(config.modResults, 'android.permission.VIBRATE');
    return config;
  });
};

Referencing it from app.config.ts’s `plugins array means the permission is reapplied on every prebuild, on every machine and every CI run, without anyone remembering to re-edit a generated file. A published Expo module conventionally ships its plugin as app.plugin.js at the package root so consumers can list the package name alone. Building and Publishing covers prebuild and the native-project lifecycle in full.

The Legacy API and the Interop Layer

Older tutorials, blog posts, and unmigrated libraries use a different API, and it is worth being able to recognise it — but it is history, not a way to write anything new. The legacy approach had no spec and no Codegen: a native module was annotated with @ReactMethod (Android) or RCT_EXPORT_METHOD (iOS), registered eagerly at startup, and reached from JavaScript as NativeModules.HapticTap — an untyped object whose methods existed or did not, with any mismatch surfacing only as a runtime failure. Native views were declared with requireNativeComponent('PlaceMapView'), equally untyped. Every call in both directions was serialized to JSON and posted asynchronously across the bridge, so nothing could return a value synchronously.

None of that is available for new code. React Native 0.82 made the New Architecture the only architecture and 0.84 deleted the legacy bridge’s code, so NativeModules and requireNativeComponent are not alternatives you may still choose — they are the shape of code you may still encounter. What keeps such code running is the Legacy Interop Layer: a shim that lets an unmigrated third-party native module or view manager operate under bridgeless JSI without being rewritten, by presenting the old module and view-manager registration surfaces on top of the Turbo Module and Fabric machinery. It is a migration aid with real limitations — it cannot provide synchronous calls, and a module doing anything unusual with the bridge may not work at all — and it is enabled per-module in the app’s native configuration rather than globally. Places depends on no such module and never enables it; for any module you write yourself, the Turbo Native Module or Expo Modules API paths above are the only supported options.