[ Documentation ]
Writing Custom Stubs

Writing Custom Stubs

When rnx doesn’t have a builtin stub for a package you use, you can provide your own through rnx.config.ts. Three slots, picked by what the guest bundle actually requests:

  • +
    modules: generic Metro module overrides (matched by path fragment)
  • +
    turboModules: TurboModuleRegistry name overrides
  • +
    nativeModules: legacy NativeModules name overrides

See Configuration for the full surface.

The golden rule: only stub native modules

Never stub a pure JS package. rnx simulates React Native well enough that pure-JS libraries (even when named react-native-*) work from the bundle without a stub. Composing View, Text, TouchableOpacity, etc. is already supported.

Before authoring a stub, verify the package has native code:

  • +
    NativeModules.X, TurboModuleRegistry.get('X'), or requireNativeComponent('X') somewhere in its JS
  • +
    ios/ or android/ directory with ObjC/Swift/Java/Kotlin sources
  • +
    A .podspec or build.gradle with native dependencies

Packages that need stubs: react-native-mmkv, react-native-keychain, @react-native-async-storage/async-storage, expo-haptics, react-native-fs, etc.

Packages that do not need stubs: @gorhom/bottom-sheet (pure JS on gesture-handler + reanimated), @gorhom/portal (pure JS context), react-native-gifted-chat, react-native-calendars, and any pure-JS UI library. If a pure-JS package doesn’t render correctly under rnx, the fix is in rnx’s base RN implementation, not in a stub.

modules: generic JS module overrides

Noop (empty module)

For packages you don’t need in the simulator:

import { defineConfig } from 'rnxsim/config'
export default defineConfig({
modules: {
'react-native-analytics': 'noop',
'react-native-widgetkit': 'noop',
},
})

Inline (static value)

For config-style packages whose entire surface is data:

defineConfig({
modules: {
'react-native-config': {
inline: { API_URL: 'http://localhost:3000', ENV: 'development' },
},
},
})

Inline also works for deep file paths inside a package. Keys match the Metro module name as a path fragment, so this overrides one file:

defineConfig({
modules: {
'dist/assets/config.json': {
inline: { DefaultServerUrl: 'http://localhost:8065' },
},
},
})

File (real implementation, browser-backed)

For packages that need real behavior, point at a project-local file. The dev server compiles it on demand:

// stubs/camera.ts: uses real browser APIs
export async function takePicture() {
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
// … capture a frame and return a data URL …
return { uri, width: 400, height: 400 }
}
export async function requestPermission() {
return { status: 'granted' }
}
defineConfig({
modules: {
'react-native-camera': { file: './stubs/camera.ts' },
},
})

File-stub constraints (enforced by the dev middleware, which refuses the compile and says which import it refused):

  • +
    A stub is one file. Its only static imports are react, react-native, react/jsx-runtime, and any package rnx already stubs for this app. The simulator hands each of those over as a live object, so nothing is read from node_modules.
  • +
    No runtime require() calls
  • +
    .js / .jsx / .ts / .tsx only

If you need a third-party JS lib inside a stub, vendor the relevant code into the file or replace it with browser-native APIs. To split a stub across files, import nothing: inline the helper.

Use (redirect to an existing compat stub)

If a package’s native surface matches another package rnx already stubs, redirect rather than re-implementing:

defineConfig({
modules: {
'my-haptics-fork': { use: 'expo-haptics' },
},
})

false (disable rnx’s builtin stub)

When the upstream package’s JS is correct on its own and you only need rnx to stop redirecting it to a builtin stub:

defineConfig({
modules: {
// mattermost patches react-native-keychain's JS in its own repo;
// let it run, and provide only the native manager seam separately.
'react-native-keychain': false,
},
nativeModules: {
RNKeychainManager: { file: './stubs/keychain.ts' },
},
})

turboModules and nativeModules: native-seam overrides

If the guest bundle reaches the native side via TurboModuleRegistry.get('Foo') or NativeModules.Foo, the JS-side modules slot doesn’t help; you need the native-seam slot.

Mattermost is the canonical example, mixing both:

defineConfig({
turboModules: {
// a TurboModule the bundle requests by name
RNCAsyncStorage: { file: './stubs/async-storage.ts' },
},
nativeModules: {
// legacy NativeModules names
RNKeychainManager: { file: './stubs/keychain.ts' },
RNUtils: { file: './stubs/rn-utils.ts' },
GenericClient: { file: './stubs/network-client.ts' },
ApiClient: { file: './stubs/network-client.ts' },
WebSocketClient: { file: './stubs/network-client.ts' },
},
})

Both slots accept the same ModuleResolution kinds ('noop', false, { use }, { file }, { inline }) plus a direct Record<string, any> native-module object for non-URL configs.

Authoring guidelines

  • +
    Export the same named exports as the original package (matching its type signatures). If the package’s default export is a function, your stub’s default export should be a function too; the bundle loader preserves callable default exports.
  • +
    Use real browser APIs where they exist: camera → getUserMedia, clipboard → navigator.clipboard, file picker → <input type="file">, haptics → navigator.vibrate. The goal is real behavior, not a noop.
  • +
    For UI components, return a View/Text wrapper that renders children rather than null, since most callers depend on layout being preserved.
  • +
    For hooks, return sensible defaults that satisfy the type but don’t pretend the native side is doing real work (e.g. permission hooks should return granted only if the browser actually granted the analogous permission).
  • +
    Cite the upstream source you matched. rnx’s compat philosophy is “study upstream, mirror exactly”: comments referencing the real package’s file/line make later updates safe.
  • +
    Never stub a pure-JS package. If it doesn’t render, fix rnx’s base RN implementation instead.