Skip to main content
モバイルアプリ開発(Expo / React Native)
React Native
Expo
Swift
iOS
Native Modules
Mobile Apps
TypeScript
Architecture
Testing

React Native / Expo × Swift Native Modules: Choosing Between the Expo Modules API and Turbo Native Modules, with Production Code

How to call Swift iOS APIs from React Native / Expo through a native module. When to use the Expo Modules API (SDK 57) versus Turbo Native Modules, and how to handle async/await, Records, error codes, events, SharedObjects, Jest tests and the privacy manifest — explained with the official docs and a real app's code.

Published
Reading time
33 min read
Author
友田 陽大
Share

Expo and React Native cover almost everything. Real products still hit a wall eventually: there is no library that calls the iOS feature you need. On-device OCR, proving device integrity with App Attest, WidgetKit and Live Activities, a vendor SDK your client mandates. At that point the only way forward is to write a native module in Swift and call it from JS.

I have written seven local Expo modules for my own app, MemoryHack AI, which is preparing for release (you photograph a textbook page and AI turns it into flashcards). Six of them have a Swift implementation on iOS and three have a Kotlin implementation on Android. This article uses that code — and the incidents I ran into while writing it — to explain which approach to use, when, and how, following the primary sources: the Expo docs, the React Native docs, and Apple's and Swift's documentation.

Baseline versions: Expo SDK 57 (React Native 0.86 / React 19.2 / expo-modules-core 57.0.x). Runtime behaviour described here was checked against the sdk-57 branch of the expo/expo repository in addition to the docs. The app the examples come from runs on SDK 54, but the Module API DSL used here is the same in SDK 54 and 57.

0. The short answer: which approach to pick

Here is the decision table up front. Work down it and take the first row that applies.

SituationChooseWhy
An existing Expo SDK or community library already does itUse that firstYou don't carry the maintenance. Not writing native code is always cheapest
A feature specific to your own Expo app, written in SwiftExpo Modules API (local module)Swift DSL only. No Objective-C++ glue, no Codegen. Doesn't fight CNG
A quick experiment, as fast as possibleExpo Modules API inline modulesAn experimental feature in SDK 56+. The API may change in breaking ways, so don't build production on it
A general-purpose library that must not depend on expo, or you need C++Turbo Native Modules (Codegen)React Native's own mechanism. Using Swift requires an Objective-C++ adapter
Logic shared across platforms, written in C++C++ Turbo ModuleOut of scope here (see React Native's “Pure C++ Modules”)

The Expo docs summarise the React Native team's recommendation like this:

  • If you intend to use C++, use Turbo Modules (it gives easier access to lower-level mechanisms).
  • If you want the better developer experience and can depend on the expo package, use the Expo Modules API.

Performance is not a reason to choose one over the other. According to the docs, both Expo Modules and Turbo Modules run on React Native's JSI (JavaScript Interface), and both can easily execute hundreds of thousands of native calls per second. Their position is that the time spent in the body of a native method is often orders of magnitude greater than the call overhead. So what deserves your design attention is not the number of calls, but how heavy each call is.


I can take on the implementation from this article as an engagement

iOS native features (Swift) for Expo / React Native apps — from design through App Review

1. Mental model: the “bridge” is no longer a JSON message queue

The “bridge” in older React Native write-ups was a mechanism where JS and native exchanged JSON messages over an asynchronous queue. Today, both Expo Modules and Turbo Modules use JSI to expose native functions directly as JS objects. From Expo SDK 55, the New Architecture is always on and cannot be disabled (more in the Expo production guide).

The first thing to get right is what runs on which thread. Here is the execution model of the Expo Modules API on iOS, compiled from the docs and the SDK 57 source:

DefinitionWhat JS seesWhere the native side runsUse it for
FunctionReturns synchronouslyThe JS thread. Script execution is blocked until it returnsInstant reads (capability checks, computing a constant)
AsyncFunction (plain closure)A PromiseBy default, one serial queue shared by every module (expo.modules.AsyncFunctionQueue, QoS userInitiated). Change it with .runOnQueue(...)I/O, heavy computation, work that needs the main thread
AsyncFunction (async closure)A PromiseSwift Concurrency (the closure is @Sendable). There is no .runOnQueueWrapping async OS APIs (App Attest, for example)
AsyncFunction inside a ViewA method on the refThe UI thread by defaultView operations such as focus()
A function taking JavaScriptValueSynchronous onlyThe JS thread (touching it from another thread crashes)Special cases that mutate a JS object directly

The second row is a production pitfall the docs don't mention. In the SDK 57 source (AsyncFunctionDefinition.swift), the default queue is a single file-level private let defaultQueue = DispatchQueue(label: "expo.modules.AsyncFunctionQueue", qos: .userInitiated). It is a serial queue, so a multi-second OCR call in one module holds up the AsyncFunctions of every other module. Anything that might take more than a few hundred milliseconds belongs on a dedicated queue or in an async closure (implemented in §3).

Next, the types you can pass between JS and Swift (from “Argument types” in the Module API Reference):

SwiftJS / TSNotes
Bool / the Int family / Double / Stringboolean / number / stringArrays, dictionaries and Optionals of these too
A struct conforming to RecordAn objectEach field has its own type and can have a default. Works as a return type too
An enum conforming to EnumerableA union of strings (or numbers)Out-of-range values are rejected with EnumNoSuchValueException before your code runs
Either<A, B> and friendsOne of the typesUp to four types
URLstringA string without a scheme is treated as a file URL
DataUint8ArraySDK 50+. Pass binary data without base64
CGPoint / CGSize / CGRect / UIColorObjects, arrays, color stringsBuilt-in Convertibles
A SharedObject subclassAn instance of a JS classLets JS keep a reference to native state (§6)

Taking a [String: Any] and checking types by hand is almost always a mistake. With Record and Enumerable, validation and conversion happen automatically before the call reaches your code, and failures become exceptions that name the cause. The official tutorial shows this error as an example:

Error: FunctionCallException: Calling the 'setTheme' function has failed
→ Caused by: ArgumentCastException: Argument at index '0' couldn't be cast to type Enum<Theme>
→ Caused by: EnumNoSuchValueException: 'not-a-real-theme' is not present in Theme enum, it must be one of: 'light', 'dark', 'system'

One caveat. @Field is a reference type under the hood (public final class Field in the SDK 57 source). So when you copy a Record struct, the copy shares its field values with the original. Use Records only to read input at the boundary and to assemble return values — don't pass them around inside the app as if they were value types.


2. The minimal setup: creating a local module

A module specific to your app lives inside the app's repository as a local module (it is never published to npm).

npx create-expo-module@latest --local   # creates modules/<name>/
npx expo prebuild --clean               # regenerates the native project and autolinks the module
npx expo run:ios                        # your own native code doesn't run in Expo Go — verify in a development build

The generated layout looks like this. Autolinking searches ./modules/ by default (autolinking's nativeModulesDir).

modules/text-recognition/
├── expo-module.config.json   # which Swift classes are registered as modules
├── index.ts                  # the public API the app imports (the wrapper)
├── src/
│   └── TextRecognitionModule.ts  # only requireNativeModule and type declarations
└── ios/
    ├── MyAppTextRecognition.podspec  # ← without this, the module is silently not linked (see below)
    └── TextRecognitionModule.swift
{
  "platforms": ["apple", "android"],
  "apple": { "modules": ["TextRecognitionModule"] },
  "android": { "modules": ["expo.modules.textrecognition.TextRecognitionModule"] }
}

The SDK 57 template uses the apple key. An ios key from older templates still works, because autolinking reads rawConfig.apple ?? rawConfig.ios.

2-1. Incident 1: a module without a podspec silently doesn't exist

This one happened in my own app. I assumed expo-module.config.json plus the Swift files was enough and never added a podspec — and the module was never linked at all. Reading the SDK 57 autolinking source (platforms/apple/apple.ts), resolveModuleAsync just returns null when it can't find a single .podspec. No error.

The real damage comes from combining that with this JS pattern:

// ❌ "Not linked" looks exactly the same as "unsupported device"
try {
  return requireNativeModule('DeviceCheck');
} catch {
  return null; // meant to treat it like the simulator or Android
}

null is the correct result on the simulator and on Android, so the tests and the UI both looked fine. In production on iOS it was also null, every time. The server-side check that used the value happened to be switched off, so about 7 weeks went by before anyone noticed. §9 shows how to make this impossible to miss: a startup self-check.

2-2. Incident 2: a pod name that hides an Apple framework

Adding the podspec has its own trap. If a pod with DEFINES_MODULE = YES has the same name as an Apple framework (DeviceCheck, Vision, WidgetKit, …), then import DeviceCheck in your sources resolves to the pod itself instead of Apple's framework. In my app I avoided it with an app-specific prefix, as in MemoryHackDeviceCheck.

# ios/MyAppTextRecognition.podspec
Pod::Spec.new do |s|
  s.name           = 'MyAppTextRecognition'   # name it 'Vision' and import Vision points at itself
  s.version        = '1.0.0'
  s.summary        = 'On-device text recognition (Vision).'
  s.author         = ''
  s.homepage       = 'https://docs.expo.dev/modules/'
  s.platforms      = { :ios => '16.4' }       # the SDK 57 template's value — match your app's minimum OS
  s.source         = { git: '' }
  s.static_framework = true
  s.dependency 'ExpoModulesCore'
  s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
  s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end

3. Example 1: on-device OCR (Vision) — AsyncFunction, Records, enums, typed exceptions

The first example is a module that reads a photographed textbook page entirely on the device. Vision ships with iOS, so there's no new dependency — the image never leaves the phone, and there is no API bill.

Why my app needed this was a little surprising. When I asked a generative AI model to return coordinates for where the answer sat on the page, its boxes correctly covered the answer in 0 of 88 questions (measured 2026-09-14 on 21 pages of Japanese primary-source text). Searching the on-device OCR output for the answer string instead located it in 86 of 93 questions, and every one of those 86 boxes covered the answer tightly. Measure positions on the device; don't ask the model. A native module is how you implement that kind of decision.

3-1. The Swift side

// modules/text-recognition/ios/TextRecognitionModule.swift
import ExpoModulesCore
import UIKit
import Vision

/// Options passed from JS. Fields omitted on the JS side get their defaults
struct ReadPageOptions: Record {
  @Field var level: RecognitionLevel = .accurate
  @Field var languages: [String] = ["ja-JP", "en-US"]
  /// OFF by default. See 3-2 for why
  @Field var usesLanguageCorrection: Bool = false
}

enum RecognitionLevel: String, Enumerable {
  case fast
  case accurate

  var vision: VNRequestTextRecognitionLevel { self == .fast ? .fast : .accurate }
}

/// Making the return value a Record too keeps it one-to-one with the JS types
struct RecognizedCharacter: Record {
  @Field var text: String = ""
  @Field var xmin: Int = 0
  @Field var ymin: Int = 0
  @Field var xmax: Int = 0
  @Field var ymax: Int = 0
}

struct RecognizedLine: Record {
  @Field var characters: [RecognizedCharacter] = []
}

public final class TextRecognitionModule: Module {
  /// Vision is heavy. Run it on a dedicated serial queue so it doesn't block the shared default queue
  private static let queue = DispatchQueue(label: "app.text-recognition", qos: .userInitiated)

  public func definition() -> ModuleDefinition {
    Name("TextRecognition")

    // A capability check can be synchronous. Callers use it to decide whether to try at all
    Function("supportedLanguages") { (level: RecognitionLevel) -> [String] in
      Self.supportedLanguages(level: level)
    }

    AsyncFunction("readPage") { (url: URL, options: ReadPageOptions) throws -> [RecognizedLine] in
      // Trust boundary: local files only. A module that fetches arbitrary URLs becomes a request-forgery tool
      guard url.isFileURL else { throw NotAFileURLException() }
      guard let image = UIImage(contentsOfFile: url.path), let cgImage = image.cgImage else {
        throw ImageUnreadableException()
      }

      // Available languages differ by OS. Ask what actually works instead of hard-coding versions
      let available = Set(Self.supportedLanguages(level: options.level))
      let languages = options.languages.filter(available.contains)
      guard !languages.isEmpty else { throw LanguageUnavailableException(options.languages) }

      let request = VNRecognizeTextRequest()
      request.recognitionLevel = options.level.vision
      request.usesLanguageCorrection = options.usesLanguageCorrection
      request.recognitionLanguages = languages

      let handler = VNImageRequestHandler(
        cgImage: cgImage,
        orientation: CGImagePropertyOrientation(image.imageOrientation)
      )
      do {
        try handler.perform([request])
      } catch {
        throw RecognitionFailedException().causedBy(error)
      }

      return (request.results ?? []).compactMap { observation in
        guard let candidate = observation.topCandidates(1).first else { return nil }
        let line = RecognizedLine()
        line.characters = PageGeometry.characters(of: candidate)
        return line
      }
    }
    .runOnQueue(Self.queue)
  }

  private static func supportedLanguages(level: RecognitionLevel) -> [String] {
    let request = VNRecognizeTextRequest()
    request.recognitionLevel = level.vision
    return (try? request.supportedRecognitionLanguages()) ?? []
  }
}

Coordinate conversion lives in a pure type, separate from the bridge, so that part alone can be unit-tested with XCTest (§9).

// modules/text-recognition/ios/PageGeometry.swift
import UIKit
import Vision

/// Converts Vision's normalised coordinates (origin bottom-left, 0...1) to the app's (origin top-left, 0...1000)
enum PageGeometry {
  static let space: CGFloat = 1000

  static func characters(of candidate: VNRecognizedText) -> [RecognizedCharacter] {
    let string = candidate.string
    return string.indices.compactMap { index in
      let range = index..<string.index(after: index)
      guard let rect = try? candidate.boundingBox(for: range)?.boundingBox,
            let box = box(from: rect) else { return nil }
      // @Field is a reference-type (final class) wrapper, so values can be set through a let
      let character = RecognizedCharacter()
      character.text = String(string[range])
      (character.xmin, character.ymin, character.xmax, character.ymax) = box
      return character
    }
  }

  /// nil for anything that isn't a real box. Never fill in a guess
  static func box(from rect: CGRect) -> (Int, Int, Int, Int)? {
    // Int(_:) traps (crashes) on a non-finite value. Don't trust values that come from a photo
    guard rect.minX.isFinite, rect.maxX.isFinite, rect.minY.isFinite, rect.maxY.isFinite,
          rect.width > 0, rect.height > 0 else { return nil }
    // The Y-axis flip happens here and nowhere else. Flip twice and an upside-down box still passes range checks
    return (
      coordinate(rect.minX, .down), coordinate(1 - rect.maxY, .down),
      coordinate(rect.maxX, .up), coordinate(1 - rect.minY, .up)
    )
  }

  /// Always round outward. Rounding inward leaves the edge of a hidden character showing
  static func coordinate(_ value: CGFloat, _ rule: FloatingPointRoundingRule) -> Int {
    Int(min(max((value * space).rounded(rule), 0), space))
  }
}

extension CGImagePropertyOrientation {
  /// Map name to name. The two enums number the same cases differently (.right is 3 in one, 6 in the other)
  init(_ orientation: UIImage.Orientation) {
    switch orientation {
    case .up: self = .up
    case .upMirrored: self = .upMirrored
    case .down: self = .down
    case .downMirrored: self = .downMirrored
    case .left: self = .left
    case .leftMirrored: self = .leftMirrored
    case .right: self = .right
    case .rightMirrored: self = .rightMirrored
    @unknown default: self = .up
    }
  }
}
// modules/text-recognition/ios/TextRecognitionExceptions.swift
import ExpoModulesCore

// Unless overridden, code is derived from the class name.
// Names containing acronyms come out mangled: NotAFileURLException → "ERR_NOT_AFILE_UR_L" (computed with the SDK 57 rule).
// Override the codes JS branches on, so renaming the class can't break the contract
final class NotAFileURLException: Exception, @unchecked Sendable {
  override var code: String { "ERR_TEXT_RECOGNITION_NOT_A_FILE_URL" }
  override var reason: String { "Only local file URLs are accepted." }
}

final class ImageUnreadableException: Exception, @unchecked Sendable {
  override var reason: String { "The image could not be opened." }
}

final class LanguageUnavailableException: GenericException<[String]>, @unchecked Sendable {
  override var code: String { "ERR_TEXT_RECOGNITION_LANGUAGE_UNAVAILABLE" }
  override var reason: String { "None of the requested languages is available on this OS: \(param)" }
}

final class RecognitionFailedException: Exception, @unchecked Sendable {
  override var reason: String { "Text recognition failed for this image." }
}

3-2. Four things the docs alone won't tell you

  1. Never convert orientation by rawValue. UIImage.Orientation and CGImagePropertyOrientation describe the same eight orientations but number them differently. A photo taken in portrait is UIImage.Orientation.right (rawValue 3), which is 6 in EXIF. Convert by rawValue and you measure coordinates on an image rotated by 90 degrees.
  2. Boxes with zero width and height come back. For space characters, Vision sometimes returns a zero-size rectangle at the bottom-left corner of the page. In my measurements (50 pages, 34,021 characters), 211 characters were like this. Use such a box as-is and the area you mask stretches from the answer all the way to the corner of the page.
  3. Whether to turn off language correction depends on the use case. Apple's documentation says that setting usesLanguageCorrection to false gives performance benefits but less accurate results. But when you are checking text against the original, correction rewrites older spellings into their “correct” modern forms (for example 「あつた」 → 「あった」). You would then wrongly confirm a reworded quotation as verbatim. In my measurements, page-level similarity stayed at 0.991 or above (median 0.997) with correction off, so the price was negligible.
  4. Don't hard-code which OS versions support ja-JP. In my testing it was iOS 16 and later, but the implementation calls supportedRecognitionLanguages() (iOS 15+) to find the languages actually available. An English-only recogniser reading a Japanese page returns a scattering of Latin characters. Read that as “this page has no text” and you make a wrong call.

3-3. The TypeScript side: express failure kinds in types

The JS side is split into two files: one that holds only the native boundary, and the public API (the wrapper) the app uses (SRP). Mock the first one alone and you can test the wrapper's logic in Jest.

// modules/text-recognition/src/TextRecognitionModule.ts
import { NativeModule, requireOptionalNativeModule } from 'expo';

export type RecognitionLevel = 'fast' | 'accurate';

export interface ReadPageOptions {
  readonly level?: RecognitionLevel;
  readonly languages?: readonly string[];
  readonly usesLanguageCorrection?: boolean;
}

export interface RecognizedCharacter {
  readonly text: string;
  readonly xmin: number;
  readonly ymin: number;
  readonly xmax: number;
  readonly ymax: number;
}

export interface RecognizedLine {
  readonly characters: readonly RecognizedCharacter[];
}

declare class TextRecognitionNativeModule extends NativeModule {
  supportedLanguages(level: RecognitionLevel): string[];
  readPage(uri: string, options: ReadPageOptions): Promise<RecognizedLine[]>;
}

/** null when not found (Android not implemented, web, not linked). Never throws */
export default requireOptionalNativeModule<TextRecognitionNativeModule>('TextRecognition');
// modules/text-recognition/index.ts
import TextRecognition, { type ReadPageOptions, type RecognizedLine } from './src/TextRecognitionModule';

export type { ReadPageOptions, RecognizedCharacter, RecognizedLine } from './src/TextRecognitionModule';

/** The code Swift's LanguageUnavailableException overrides. Its spelling is pinned by a contract test */
export const LANGUAGE_UNAVAILABLE = 'ERR_TEXT_RECOGNITION_LANGUAGE_UNAVAILABLE';

export type ReadPageResult =
  | { readonly status: 'read'; readonly lines: readonly RecognizedLine[]; readonly text: string }
  | { readonly status: 'unavailable'; readonly reason: 'module-missing' | 'language-missing' }
  | { readonly status: 'failed'; readonly code: string };

function errorCode(error: unknown): string {
  return typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string'
    ? error.code
    : 'ERR_UNKNOWN';
}

export async function readPage(uri: string, options: ReadPageOptions = {}): Promise<ReadPageResult> {
  if (TextRecognition === null) return { status: 'unavailable', reason: 'module-missing' };
  try {
    const lines = await TextRecognition.readPage(uri, options);
    // Build the text from characters that have boxes, so the string you match and the boxes you mask never disagree
    const text = lines.map((line) => line.characters.map((c) => c.text).join('')).join('\n');
    return { status: 'read', lines, text };
  } catch (error) {
    const code = errorCode(error);
    return code === LANGUAGE_UNAVAILABLE
      ? { status: 'unavailable', reason: 'language-missing' }
      : { status: 'failed', code };
  }
}

The point is not to collapse “couldn't read” into a single null. The UI may treat everything other than read the same way (show nothing, claim nothing), but observability must be able to tell module-missing from failed — the incident in §2-1 happened precisely because it couldn't. Conversely, a photo that was read and has zero characters (read with empty text) is the one case where a caller may conclude “this page has no text”.


4. Example 2: wrapping async OS APIs with Swift Concurrency (App Attest)

The second example is Apple's App Attest (DCAppAttestService). Against a server-issued challenge, it uses a Secure Enclave key to prove that the caller is a genuine instance of this app on genuine Apple hardware. The OS API is async, so an async throws closure wraps it directly.

// modules/app-attest/ios/AppAttestModule.swift
import CryptoKit
import DeviceCheck
import ExpoModulesCore

public final class AppAttestModule: Module {
  public func definition() -> ModuleDefinition {
    Name("AppAttest")

    // Always false on the simulator
    Function("isSupported") { () -> Bool in
      DCAppAttestService.shared.isSupported
    }

    // Only the key ID goes back to JS. The private key never leaves the Secure Enclave; no API can export it
    AsyncFunction("generateKey") { () async throws -> String in
      guard DCAppAttestService.shared.isSupported else { throw AppAttestUnsupportedException() }
      return try await DCAppAttestService.shared.generateKey()
    }

    AsyncFunction("generateAssertion") { (keyId: String, challengeBase64: String) async throws -> String in
      guard let challenge = Data(base64Encoded: challengeBase64) else {
        throw AppAttestInvalidChallengeException()
      }
      do {
        // Hash on the native side, so client and server can never disagree about what was signed
        let assertion = try await DCAppAttestService.shared.generateAssertion(
          keyId, clientDataHash: Data(SHA256.hash(data: challenge))
        )
        return assertion.base64EncodedString()
      } catch let error as DCError where error.code == .invalidKey {
        // Convert only the failure we need to tell apart. Every other DCError stays ERR_UNEXPECTED
        throw AppAttestInvalidKeyException()
      }
    }
  }
}

final class AppAttestUnsupportedException: Exception, @unchecked Sendable {
  override var reason: String { "App Attest is not supported on this device." }
}

final class AppAttestInvalidChallengeException: Exception, @unchecked Sendable {
  override var reason: String { "Challenge was not valid base64." }
}

/// The key ID survives in the Keychain, but the key itself is lost on reinstall or device migration
final class AppAttestInvalidKeyException: Exception, @unchecked Sendable {
  // JS branches on this exact spelling. Pin it instead of deriving it from the class name
  override var code: String { "ERR_APP_ATTEST_INVALID_KEY" }
  override var reason: String { "The App Attest key is no longer held by this device." }
}

4-1. How an error code reaches JS (verified in the SDK 57 source)

  • Unless overridden, an Exception's code is derived from the class name: strip a trailing Exception / Error, convert to snake case, uppercase, and prefix ERR_ (errorCodeFromString).
  • An Exception thrown inside a function is wrapped in a FunctionCallException before it reaches JS. But FunctionCallException's code is implemented to return the underlying cause's code, so JS sees ERR_APP_ATTEST_INVALID_KEY.
  • Errors that are not an Exception (DCError, NSError, …) are wrapped in UnexpectedException and all look like ERR_UNEXPECTED from JS. For any failure you need to distinguish, convert it into an Exception in Swift first.

4-2. Why separate “invalid key” from “transient failure” (idempotency)

The JS side splits the result three ways:

export type AssertionResult =
  | { readonly status: 'signed'; readonly assertion: string }
  | { readonly status: 'invalid_key' } // only this one regenerates the key
  | { readonly status: 'failed' };     // transient. Keep using the same key

Treat a transient failure as invalid_key and you regenerate the key and move the server's device-to-key binding on every hiccup. Treat a genuinely lost key as failed and a reinstalled device keeps presenting a dead key and keeps getting rejected. Both mistakes are real outages. Apple's documentation also says that because there's no way to use the key without its identifier, you should record it in your app or on your server right away — an implementation that generates a key on every launch is wrong.

4-3. Touching UIKit from an async closure

An async closure (ConcurrentFunctionDefinition) has no .runOnQueue — in the SDK 57 source, only the definition for plain closures has runOnQueue. When you need UIKit state, hop to the main actor explicitly:

AsyncFunction("isAppActive") { () async -> Bool in
  await MainActor.run { UIApplication.shared.applicationState == .active }
}

In SDK 57 the async closure type is @Sendable. In the Swift 6 language mode, capturing a non-Sendable value in such a closure is a compile error. If your module has mutable state, confine that state to one queue or one actor.


5. Example 3: events from native to JS (Events / OnStartObserving)

The third example goes the other way: native code notifying JS. We subscribe to whether the connection is expensive (cellular, hotspot) or in Low Data Mode, and adjust how a sync queue sends. Before writing it, check that expo-network or another existing library doesn't already do what you need (the first row of the §0 table).

// modules/network-quality/ios/NetworkQualityModule.swift
import ExpoModulesCore
import Network

public final class NetworkQualityModule: Module {
  private var monitor: NWPathMonitor?
  private let queue = DispatchQueue(label: "app.network-quality")

  public func definition() -> ModuleDefinition {
    Name("NetworkQuality")
    Events("onChange")

    // Start monitoring when the first listener is added. No battery spent while nobody listens
    OnStartObserving("onChange") {
      let monitor = NWPathMonitor() // create a fresh one on each start; don't reuse after cancel
      monitor.pathUpdateHandler = { [weak self] path in
        self?.sendEvent("onChange", [
          "isConnected": path.status == .satisfied,
          "isExpensive": path.isExpensive,     // cellular, personal hotspot
          "isConstrained": path.isConstrained, // Low Data Mode
        ])
      }
      monitor.start(queue: queue)
      self.monitor = monitor
    }

    // Stop when the last listener is removed
    OnStopObserving("onChange") {
      monitor?.cancel()
      monitor = nil
    }

    OnDestroy {
      monitor?.cancel()
    }
  }
}

sendEvent is safe to call from any thread. In the SDK 57 source, the event is handed to the JS thread with runtime.schedule before it is delivered.

On the TS side, declare a typed event map and subscribe with useEvent from expo.

// modules/network-quality/index.ts
import { EventEmitter, NativeModule, requireOptionalNativeModule, useEvent } from 'expo';

export interface NetworkQuality {
  readonly isConnected: boolean;
  readonly isExpensive: boolean;
  readonly isConstrained: boolean;
}

type Events = { onChange: (quality: NetworkQuality) => void };

declare class NetworkQualityModule extends NativeModule<Events> {}

/**
 * Where the module doesn't exist (Android not implemented, web, not linked), use an emitter that never fires.
 * That way useEvent can always be called, so no hook is called conditionally (rules-of-hooks holds)
 */
const emitter =
  requireOptionalNativeModule<NetworkQualityModule>('NetworkQuality') ?? new EventEmitter<Events>();

/** When nothing is known, assume no constraints. Failing closed would stop sync on Android and web */
const UNKNOWN: NetworkQuality = { isConnected: true, isExpensive: false, isConstrained: false };

export function useNetworkQuality(): NetworkQuality {
  return useEvent(emitter, 'onChange', UNKNOWN);
}

useEvent adds its listener on the first render and removes it on unmount. When the last listener goes, Swift's OnStopObserving runs and monitoring stops.

On the consuming side, you can then implement fine-grained policies — for example, in Low Data Mode, defer only large image uploads while text sync keeps running.


6. Stateful native resources: SharedObject

Resources that outlive a single call — a decoded image, an open PDF, a long-lived session — waste both I/O and memory if you reopen them on every call. The Expo docs provide SharedObject for exactly this: a single native instance that JS keeps referring to.

My app's PDF page-export module currently reopens the PDF every time renderPage(uri, pageIndex, maxWidth) is called. Generating a few hundred thumbnails for a textbook chapter means opening the same PDF a few hundred times. Rewritten with a SharedObject, it looks like this:

// modules/pdf-pages/ios/PdfPagesModule.swift (SharedObject version)
import ExpoModulesCore
import PDFKit

final class PdfDocumentObject: SharedObject {
  let document: PDFDocument
  let pageCount: Int // kept as an immutable value, so reads from the JS thread can't race

  init(document: PDFDocument) {
    self.document = document
    self.pageCount = document.pageCount
    super.init()
  }
}

public final class PdfPagesModule: Module {
  /// A dedicated serial queue: PDFDocument is never touched from two threads at once, and other modules aren't kept waiting
  private static let renderQueue = DispatchQueue(label: "app.pdf-pages", qos: .userInitiated)

  public func definition() -> ModuleDefinition {
    Name("PdfPages")

    // The file is opened exactly once
    AsyncFunction("openAsync") { (url: URL) throws -> PdfDocumentObject in
      guard url.isFileURL, let document = PDFDocument(url: url) else { throw PdfUnreadableException() }
      return PdfDocumentObject(document: document)
    }
    .runOnQueue(Self.renderQueue)

    Class("PdfDocument", PdfDocumentObject.self) {
      Property("pageCount") { (pdf: PdfDocumentObject) -> Int in pdf.pageCount }

      AsyncFunction("renderPageAsync") { (pdf: PdfDocumentObject, pageIndex: Int, maxWidth: Double) throws -> String in
        // The renderer (PdfPageRenderer) is the original implementation extracted into a function; described below
        try PdfPageRenderer.render(pdf.document, pageIndex: pageIndex, maxWidth: maxWidth)
      }
      .runOnQueue(Self.renderQueue)
    }
  }
}

On the JS side, release an instance with release() once you're done with it. For instances you can create synchronously, useReleasingSharedObject from expo-modules-core releases them on unmount. When an instance is opened asynchronously, like openAsync, write the release yourself — including the case where the user leaves the screen while the file is still opening.

// modules/pdf-pages/index.ts
import { NativeModule, requireOptionalNativeModule, SharedObject } from 'expo';
import { useEffect, useState } from 'react';

declare class PdfDocument extends SharedObject {
  readonly pageCount: number;
  renderPageAsync(pageIndex: number, maxWidth: number): Promise<string>;
}

declare class PdfPagesModule extends NativeModule {
  openAsync(uri: string): Promise<PdfDocument>;
}

const PdfPages = requireOptionalNativeModule<PdfPagesModule>('PdfPages');

/** Released when uri changes or the screen goes away. Leaving before it finishes opening leaves no native PDF behind */
export function usePdfDocument(uri: string | null): PdfDocument | null {
  const [document, setDocument] = useState<PdfDocument | null>(null);

  useEffect(() => {
    if (uri === null || PdfPages === null) return;
    let disposed = false;
    let opened: PdfDocument | null = null;

    PdfPages.openAsync(uri).then(
      (doc) => {
        if (disposed) {
          doc.release(); // too late to use it — release it the moment it arrives
          return;
        }
        opened = doc;
        setDocument(doc);
      },
      () => setDocument(null),
    );

    return () => {
      disposed = true;
      opened?.release();
      setDocument(null);
    };
  }, [uri]);

  return document;
}

If the native side needs cleanup, override sharedObjectDidRelease(). The body of PdfPageRenderer.render carries over from my app unchanged: never upscale; fill with white before drawing, because JPEG has no transparency; put the width in the file name so the same input always produces the same file (idempotent); write with atomic; and store under Caches, since the OS may delete the files and they can always be regenerated.


7. React Native's own approach: Turbo Native Modules in Swift

If you are building a general-purpose library that must not depend on expo, or your app doesn't use Expo, use React Native's own Turbo Native Modules. The official flow has four steps: write a typed spec → configure Codegen → write the app code → implement the native side against the generated interfaces.

// specs/NativeLocalStorage.ts — file and module names start with "Native" (an official rule)
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  setItem(value: string, key: string): void;
  getItem(key: string): string | null;
  removeItem(key: string): void;
  clear(): void;
}

export default TurboModuleRegistry.getEnforcing<Spec>('NativeLocalStorage');
{
  "codegenConfig": {
    "name": "NativeLocalStorageSpec",
    "type": "modules",
    "jsSrcsDir": "specs",
    "android": { "javaPackageName": "com.nativelocalstorage" },
    "ios": { "modulesProvider": { "NativeLocalStorage": "RCTNativeLocalStorage" } }
  }
}

For Swift, the official docs use the Adapter pattern. React Native's core is written in C++ and Swift/C++ interop isn't good enough, so a thin Objective-C++ layer forwards calls to the Swift implementation.

// NativeLocalStorage.swift — all the real work lives in Swift
@objcMembers public class NativeLocalStorage: NSObject {
  let userDefaults = UserDefaults(suiteName: "local-storage")

  public func getItem(for key: String) -> String? { userDefaults?.string(forKey: key) }
  public func setItem(for key: String, value: String) { userDefaults?.set(value, forKey: key) }
  public func removeItem(for key: String) { userDefaults?.removeObject(forKey: key) }
  public func clear() { userDefaults?.dictionaryRepresentation().keys.forEach { removeItem(for: $0) } }
}
// RCTNativeLocalStorage.mm — conforms to the Codegen protocol and forwards to Swift, nothing more
#import "RCTNativeLocalStorage.h"
#import "SampleApp-Swift.h" // Swift's public header generated by Xcode ("SampleApp" is your app name)

@implementation RCTNativeLocalStorage {
  NativeLocalStorage *storage;
}

- (id)init {
  if (self = [super init]) { storage = [NativeLocalStorage new]; }
  return self;
}

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

- (NSString *_Nullable)getItem:(NSString *)key { return [storage getItemFor:key]; }
- (void)setItem:(NSString *)value key:(NSString *)key { [storage setItemFor:key value:value]; }
- (void)removeItem:(NSString *)key { [storage removeItemFor:key]; }
- (void)clear { [storage clear]; }

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

Side by side, the differences are clear:

AspectExpo Modules APITurbo Native Modules (Swift)
Languages you writeSwift (DSL) onlyA TS spec + an Objective-C++ adapter + Swift
Source of truth for typesSwift's Record / Enumerable (TS types are hand-written; from SDK 56, expo-type-information can generate them from Swift — macOS only)The TS spec; Codegen generates the native interfaces
Errors and eventsException codes; Events / sendEventPromise rejection; CodegenTypes.EventEmitter in the spec and emitOnXxx
Fit with CNG (prebuild --clean)Good (modules/ is not generated output)The official steps edit the app's Xcode project directly (bridging header and so on). To coexist with CNG you need to extract it into a library or write a config plugin
Depends on expoYesNo
Best forFeatures specific to an Expo appGeneral-purpose libraries, C++, apps without Expo

For an app-specific feature in an Expo app, there's little reason to choose Turbo Native Modules: you would give up CNG's biggest benefit — being able to regenerate ios/ from scratch every time.


8. iOS configuration and lifecycle: entitlements, AppDelegate, privacy manifest

A native module is rarely just Swift code. Entitlements (capabilities), Info.plist, the AppDelegate and the privacy manifest are declared as configuration, just like the code. Hand edits to ios/ disappear on the next prebuild --clean.

8-1. App Groups and the privacy manifest (an easy-to-miss reason code)

To share data with a home-screen widget, you use an App Group and UserDefaults(suiteName:). The part people miss: UserDefaults is one of Apple's “required reason APIs”. Since May 1, 2024, App Store Connect has not accepted apps that use it without declaring a reason.

Which reason code you declare matters. Apple's documentation defines them like this:

Reason codeApple's definition (summarised)
CA92.1Accessing information only the app itself reads and writes. Does not permit reading what other apps wrote, or writing information other apps can read
1C8F.1Accessing information read and written only by apps, app extensions and App Clips in the same App Group

Sharing with a widget needs 1C8F.1. CA92.1 explicitly excludes “writing information that can be accessed by other apps”, so CA92.1 alone does not cover App Group sharing. It's common for a generated native project to contain only CA92.1, so open the generated PrivacyInfo.xcprivacy once and check.

// app.config.ts (excerpt)
const APP_GROUP = 'group.com.example.app';

export default (): ExpoConfig => ({
  // ...
  ios: {
    bundleIdentifier: 'com.example.app',
    entitlements: { 'com.apple.security.application-groups': [APP_GROUP] },
    privacyManifests: {
      NSPrivacyAccessedAPITypes: [
        {
          NSPrivacyAccessedAPIType: 'NSPrivacyAccessedAPICategoryUserDefaults',
          // CA92.1: the app's own settings / 1C8F.1: the App Group shared with the widget
          NSPrivacyAccessedAPITypeReasons: ['CA92.1', '1C8F.1'],
        },
      ],
    },
  },
});

According to the Expo docs, if you submit a build with missing declarations, Apple emails you within a few minutes. Submitting early for TestFlight external testing is the reliable way to check.

8-2. Receiving AppDelegate events

To receive events delivered to the AppDelegate — launches from a URL, remote notifications and so on — you don't have to edit AppDelegate.swift by hand. Create a class that inherits from ExpoAppDelegateSubscriber and register it in expo-module.config.json.

// modules/app-lifecycle/ios/AppLifecycleDelegate.swift
import ExpoModulesCore

public class AppLifecycleDelegate: ExpoAppDelegateSubscriber {
  public func applicationDidEnterBackground(_ application: UIApplication) {
    // For example: persist the queue of unsent work
  }
}
{ "apple": { "appDelegateSubscribers": ["AppLifecycleDelegate"] } }

When several subscribers implement a delegate method that returns a value, ExpoAppDelegate reconciles the results. didFinishLaunchingWithOptions, for example, returns true if at least one subscriber returns true. For the completion handler of didReceiveRemoteNotification, any failed makes the result failed; otherwise any newData makes it newData; otherwise it is noData. Subscribers must be Swift classes — Objective-C classes are not supported.


9. Testing strategy: assume Swift never runs in Jest, and split into three layers

LayerWhat it protectsHow
Pure Swift logicCoordinate conversion, rounding, input validationKeep it separate from the bridge like PageGeometry, and test with XCTest / Swift Testing
The JS wrapperFailure classification, defaults, platform branchesMock the native-boundary file and test in Jest
The Swift ↔ TS contractThe spelling of error codesA contract test that reads the Swift source and compares it with the strings TS uses

The wrapper tests mock only the boundary file split out in §3-3.

// modules/text-recognition/__tests__/readPage.test.ts
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

const mockReadPage = jest.fn();

jest.mock('../src/TextRecognitionModule', () => ({
  __esModule: true,
  default: { readPage: (...args: unknown[]) => mockReadPage(...args), supportedLanguages: jest.fn() },
}));

// import after jest.mock
import { LANGUAGE_UNAVAILABLE, readPage } from '..';

beforeEach(() => mockReadPage.mockReset());

test('builds the page text from the character boxes', async () => {
  mockReadPage.mockResolvedValue([
    { characters: [{ text: '条', xmin: 0, ymin: 0, xmax: 10, ymax: 10 }] },
  ]);
  await expect(readPage('file:///page.jpg')).resolves.toMatchObject({ status: 'read', text: '条' });
});

test('tells a missing language apart from a failed read', async () => {
  mockReadPage.mockRejectedValueOnce(Object.assign(new Error('x'), { code: LANGUAGE_UNAVAILABLE }));
  await expect(readPage('file:///page.jpg')).resolves.toEqual({
    status: 'unavailable',
    reason: 'language-missing',
  });

  mockReadPage.mockRejectedValueOnce(Object.assign(new Error('x'), { code: 'ERR_UNEXPECTED' }));
  await expect(readPage('file:///page.jpg')).resolves.toEqual({ status: 'failed', code: 'ERR_UNEXPECTED' });
});

test('Swift throws the code with the same spelling as TS (contract test)', () => {
  const swift = readFileSync(join(__dirname, '..', 'ios', 'TextRecognitionExceptions.swift'), 'utf8');
  // If the spelling drifts, every "missing language" silently turns into a "failure" — and nobody notices
  expect(swift).toContain(`"${LANGUAGE_UNAVAILABLE}"`);
});

For modules you distribute, the Expo docs also describe a mocks/ directory convention. npx expo-modules-test-core generate-ts-mocks can generate mocks from the Swift implementation (it requires SourceKitten). For a local module inside an app, mocking the boundary file as above makes the intent of each test clearer.

9-1. A startup self-check that makes §2-1 impossible to miss

The last layer checks whether the modules are actually linked in a real iOS build. Unit tests cannot detect this, by construction.

// src/native-modules-health.ts
import { requireOptionalNativeModule } from 'expo';
import { Platform } from 'react-native';

/** Modules every iOS build must contain. Add new ones here too */
const REQUIRED_ON_IOS = ['TextRecognition', 'AppAttest', 'NetworkQuality', 'PdfPages'] as const;

export function findMissingNativeModules(): string[] {
  if (Platform.OS !== 'ios') return [];
  return REQUIRED_ON_IOS.filter((name) => requireOptionalNativeModule(name) === null);
}

In development builds, throw at startup when findMissingNativeModules() isn't empty, so you notice immediately. In production, report the missing module names once to Sentry or similar. Add the same function to your E2E smoke test, and a build that forgot a podspec is stopped before it reaches the store.


10. Production checklist

AreaWhat to check
PerformanceIs heavy work sitting on the shared default queue (move it to a dedicated queue or async)? Is a synchronous Function doing I/O? Is binary data passed as Data ↔ Uint8Array rather than base64? Are calls split too finely?
Type safetyRecord / Enumerable instead of [String: Any]? Functions with more than three arguments folded into a Record (harder to pass arguments in the wrong order)?
ResilienceDoes JS distinguish not linked, unsupported, transient failure and permanent failure? Does no failure take the whole app down?
IdempotencyOn retry, are side effects (key regeneration, file writes, server-side rebinding) never duplicated?
ObservabilityAre Exception codes fixed and used as log / error-monitoring tags? Does a startup self-check catch unlinked modules?
SecurityIs accepted input narrowed (file URLs only, for example)? No catch-all function that can execute anything? No private keys or tokens returned to JS? (See also: the mobile app security guide)
PrivacyAre reason codes for required reason APIs (UserDefaults, file timestamps, system boot time, disk space, …) declared to match actual use?
DistributionNative changes ship as a new binary. OTA carries only JS and assets. Use the fingerprint runtimeVersion policy so an incompatible OTA update is never delivered

11. Common errors and fixes

SymptomCauseFix
Cannot find native module 'X'No prebuild / pod install, no podspec, or running in Expo Gonpx expo prebuild --clean, then launch a development build. Check that ios/*.podspec exists
import Vision but Vision's types can't be foundThe pod has the same name as an Apple frameworkGive the pod an app-specific prefix
ArgumentCastException / EnumNoSuchValueExceptionThe value passed from JS doesn't match the Swift typeAlign the TS types with the Swift Record / Enumerable (consider type generation on SDK 56+)
Another module's Promise takes ages to resolveHeavy work is occupying the default queue shared by every module.runOnQueue(a dedicated queue), or write it as an async closure
Purple runtime warnings or crashes when updating UIUIKit touched off the main thread.runOnQueue(.main), or await MainActor.run { … }
OCR coordinates rotated 90° or flipped verticallyOrientation converted by rawValue / the Y-axis origin differsMap by name. Keep the Y-axis flip in one place
Every JS error code is ERR_UNEXPECTEDNon-Exception Swift errors are thrown as-isConvert the errors you need to distinguish into Exception subclasses and fix their code

Wrap-up: a native module is boundary design

Thanks to the Expo Modules API, writing the Swift itself is no longer hard. What separates production-grade modules is the design of the boundary between JS and native.

  1. Approach: Expo Modules API for Expo-app-specific features; Turbo Native Modules for libraries that must not depend on expo, or for C++.
  2. Threads: Function runs on the JS thread, a plain AsyncFunction on a shared serial queue, async on Swift Concurrency. Move heavy work to a dedicated queue.
  3. Types: use Record / Enumerable so validation happens before the call reaches your code.
  4. Failure: make Exception codes the contract, and have JS tell apart not linked, unsupported, transient failure and permanent failure.
  5. Verification: XCTest for pure logic, Jest for the wrapper, a contract test for code spellings, and a startup self-check for missing links.

If your Expo / React Native app has a part that can only be written natively — or you need to add iOS native capabilities (OCR, App Attest, widgets, Live Activities, a vendor SDK) to an existing app — I can take it from design through implementation to App Review. Get in touch through the form below.

Frequently asked questions

Should I use the Expo Modules API or Turbo Native Modules?
If you are writing a feature for your own Expo app, start with the Expo Modules API. You write it in a Swift DSL alone — no Objective-C++ glue, no Codegen setup — and it does not fight CNG (prebuild). The Expo docs summarise the React Native team's recommendation as: use Turbo Modules if you intend to use C++, and use the Expo Modules API if you want the better developer experience and can depend on the expo package. If you are publishing a general-purpose library that must not depend on expo, choose Turbo Native Modules.
Do my own native modules run in Expo Go?
No. Expo Go only contains the native code that was compiled into it. To run your own Swift code, generate the native project with npx expo prebuild and rebuild the app as a development build (expo-dev-client) or with npx expo run:ios. After that, JS changes still reload instantly from Metro.
Can I use Swift async/await directly?
Yes. Pass an async throws closure to AsyncFunction and it runs on Swift Concurrency, returning a Promise to JS. However, .runOnQueue(.main) belongs to plain closures, so when an async closure needs UIKit, hop to the main actor explicitly, for example with await MainActor.run { … }.
How do I tell native errors apart on the JS side?
Throw a subclass of Exception. By default its code is derived from the class name (AppAttestInvalidKeyException becomes ERR_APP_ATTEST_INVALID_KEY). Override code with a fixed string and the contract survives a rename. Swift errors that are not an Exception (such as DCError) all reach JS as ERR_UNEXPECTED, so convert the ones you need to distinguish into an Exception in Swift before throwing.
Can I ship a new native module through an OTA update (EAS Update)?
No. OTA updates can only deliver JS and assets. Changing Swift code, a podspec, entitlements or Info.plist requires a new binary submitted to the store. With the fingerprint runtimeVersion policy, the runtime changes automatically whenever the native layer changes, which keeps an incompatible OTA update from reaching devices that cannot run it.
Can I test the Swift code with Jest?
No — Jest runs in Node, so Swift never executes. Split testing into three layers: (1) keep pure Swift logic such as coordinate conversion separate from the bridge and test it with XCTest / Swift Testing; (2) test the JS wrapper in Jest by mocking the native call (the Expo docs describe a mocking mechanism too); and (3) pin the spelling of the error codes Swift throws with a contract test that reads the Swift source.

References

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

iOS native features (Swift) for Expo / React Native apps — from design through App Review

I implement the iOS capabilities that existing libraries can't reach as Swift native modules: on-device OCR (Vision), device-integrity attestation with App Attest, WidgetKit and Live Activity integration, and vendor SDK integration. Having written seven local Expo modules (Swift / Kotlin) for my own app, I cover the thread design, the type and error-code contract with JS, and the privacy manifest — so the native layer, which an OTA update can't fix, is built right from the start.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading