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-57branch of theexpo/exporepository 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.
| Situation | Choose | Why |
|---|---|---|
| An existing Expo SDK or community library already does it | Use that first | You don't carry the maintenance. Not writing native code is always cheapest |
| A feature specific to your own Expo app, written in Swift | Expo Modules API (local module) | Swift DSL only. No Objective-C++ glue, no Codegen. Doesn't fight CNG |
| A quick experiment, as fast as possible | Expo Modules API inline modules | An 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 Module | Out 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
expopackage, 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:
| Definition | What JS sees | Where the native side runs | Use it for |
|---|---|---|---|
Function | Returns synchronously | The JS thread. Script execution is blocked until it returns | Instant reads (capability checks, computing a constant) |
AsyncFunction (plain closure) | A Promise | By 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 Promise | Swift Concurrency (the closure is @Sendable). There is no .runOnQueue | Wrapping async OS APIs (App Attest, for example) |
AsyncFunction inside a View | A method on the ref | The UI thread by default | View operations such as focus() |
A function taking JavaScriptValue | Synchronous only | The 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):
| Swift | JS / TS | Notes |
|---|---|---|
Bool / the Int family / Double / String | boolean / number / string | Arrays, dictionaries and Optionals of these too |
A struct conforming to Record | An object | Each field has its own type and can have a default. Works as a return type too |
An enum conforming to Enumerable | A union of strings (or numbers) | Out-of-range values are rejected with EnumNoSuchValueException before your code runs |
Either<A, B> and friends | One of the types | Up to four types |
URL | string | A string without a scheme is treated as a file URL |
Data | Uint8Array | SDK 50+. Pass binary data without base64 |
CGPoint / CGSize / CGRect / UIColor | Objects, arrays, color strings | Built-in Convertibles |
A SharedObject subclass | An instance of a JS class | Lets 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
- Never convert orientation by rawValue.
UIImage.OrientationandCGImagePropertyOrientationdescribe the same eight orientations but number them differently. A photo taken in portrait isUIImage.Orientation.right(rawValue 3), which is 6 in EXIF. Convert by rawValue and you measure coordinates on an image rotated by 90 degrees. - 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.
- Whether to turn off language correction depends on the use case. Apple's documentation says that setting
usesLanguageCorrectionto 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. - Don't hard-code which OS versions support
ja-JP. In my testing it was iOS 16 and later, but the implementation callssupportedRecognitionLanguages()(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'scodeis derived from the class name: strip a trailingException/Error, convert to snake case, uppercase, and prefixERR_(errorCodeFromString). - An
Exceptionthrown inside a function is wrapped in aFunctionCallExceptionbefore it reaches JS. ButFunctionCallException'scodeis implemented to return the underlying cause'scode, so JS seesERR_APP_ATTEST_INVALID_KEY. - Errors that are not an
Exception(DCError,NSError, …) are wrapped inUnexpectedExceptionand all look likeERR_UNEXPECTEDfrom JS. For any failure you need to distinguish, convert it into anExceptionin 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:
| Aspect | Expo Modules API | Turbo Native Modules (Swift) |
|---|---|---|
| Languages you write | Swift (DSL) only | A TS spec + an Objective-C++ adapter + Swift |
| Source of truth for types | Swift'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 events | Exception codes; Events / sendEvent | Promise 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 expo | Yes | No |
| Best for | Features specific to an Expo app | General-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 code | Apple's definition (summarised) |
|---|---|
CA92.1 | Accessing information only the app itself reads and writes. Does not permit reading what other apps wrote, or writing information other apps can read |
1C8F.1 | Accessing 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
| Layer | What it protects | How |
|---|---|---|
| Pure Swift logic | Coordinate conversion, rounding, input validation | Keep it separate from the bridge like PageGeometry, and test with XCTest / Swift Testing |
| The JS wrapper | Failure classification, defaults, platform branches | Mock the native-boundary file and test in Jest |
| The Swift ↔ TS contract | The spelling of error codes | A 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
| Area | What to check |
|---|---|
| Performance | Is 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 safety | Record / Enumerable instead of [String: Any]? Functions with more than three arguments folded into a Record (harder to pass arguments in the wrong order)? |
| Resilience | Does JS distinguish not linked, unsupported, transient failure and permanent failure? Does no failure take the whole app down? |
| Idempotency | On retry, are side effects (key regeneration, file writes, server-side rebinding) never duplicated? |
| Observability | Are Exception codes fixed and used as log / error-monitoring tags? Does a startup self-check catch unlinked modules? |
| Security | Is 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) |
| Privacy | Are reason codes for required reason APIs (UserDefaults, file timestamps, system boot time, disk space, …) declared to match actual use? |
| Distribution | Native 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
| Symptom | Cause | Fix |
|---|---|---|
Cannot find native module 'X' | No prebuild / pod install, no podspec, or running in Expo Go | npx expo prebuild --clean, then launch a development build. Check that ios/*.podspec exists |
import Vision but Vision's types can't be found | The pod has the same name as an Apple framework | Give the pod an app-specific prefix |
ArgumentCastException / EnumNoSuchValueException | The value passed from JS doesn't match the Swift type | Align the TS types with the Swift Record / Enumerable (consider type generation on SDK 56+) |
| Another module's Promise takes ages to resolve | Heavy 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 UI | UIKit touched off the main thread | .runOnQueue(.main), or await MainActor.run { … } |
| OCR coordinates rotated 90° or flipped vertically | Orientation converted by rawValue / the Y-axis origin differs | Map by name. Keep the Y-axis flip in one place |
Every JS error code is ERR_UNEXPECTED | Non-Exception Swift errors are thrown as-is | Convert 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.
- Approach: Expo Modules API for Expo-app-specific features; Turbo Native Modules for libraries that must not depend on
expo, or for C++. - Threads:
Functionruns on the JS thread, a plainAsyncFunctionon a shared serial queue,asyncon Swift Concurrency. Move heavy work to a dedicated queue. - Types: use
Record/Enumerableso validation happens before the call reaches your code. - Failure: make
Exceptioncodes the contract, and have JS tell apart not linked, unsupported, transient failure and permanent failure. - 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.