[ Documentation ]
Writing Custom Stubs

Writing Custom Stubs

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

  • +
    packages: native modules and views, keyed by the package whose native side they answer. A published peach-stub-<package> uses the same shape and needs no config line.
  • +
    modules: generic Metro module overrides (matched by path fragment)

See Configuration for the full surface.

The golden rule: only stub native modules

Never stub a pure JS package. Peach 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 Peach, the fix is in Peach’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 'peachsim/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 imports react, react-native, react/jsx-runtime, peach-kit, any package Peach already stubs for this app, and other project files by relative path (./Balance, ../shared/format). The simulator hands each package over as a live object, so nothing is read from node_modules.
  • +
    Relative imports resolve like the app’s own files: the exact path, then .js, .jsx, .ts, .tsx, then the folder’s index file. Each file loads once per launch, so stubs that import the same file share its state.
  • +
    No runtime require() calls
  • +
    .js / .jsx / .ts / .tsx only

A stub can render any UI react-native can: View, Text, Image, Pressable, Animated and the rest are the simulator’s own components. peach-kit adds what a native view uses beyond them: peach-kit/haptics plays the iOS feedback generators (haptics.impact('light')), and peach-kit/animation turns an iOS spring into the physics Animated.spring takes (Animated.spring(value, { ...spring({ duration: 0.4, bounce: 0.2 }), toValue: 1 })). If you need a third-party JS lib that Peach does not already stub, vendor the relevant code into a project file.

For a gradient blur in your own native view, import ProgressiveBlurView from peach-kit/materials. See peach-kit for its typed props and a native view stub example.

Use (redirect to an existing compat stub)

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

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

false (disable Peach’s builtin stub)

When the upstream package’s JS is correct on its own and you only need Peach 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,
},
packages: {
'react-native-keychain': { modules: { RNKeychainManager: './stubs/keychain.ts' } },
},
})

packages: native stubs, by package

In a React Native bundle, until something answers a native module or view name, a module is an inert stand-in and a view renders empty (for standalone Swift or Android Compose projects, use peach swift or peach kotlin). peach doctor lists every native name a package’s JavaScript asks for (TurboModuleRegistry, NativeModules, requireNativeModule, requireNativeComponent, requireNativeView, codegenNativeComponent) and your app’s own native modules and views, each under the package it belongs to.

Key the stubs by that package, then name its modules and views. A value is 'noop' or a path to a file, relative to peach.config.ts:

defineConfig({
packages: {
// every native name the installed package asks for becomes inert
'react-native-acme-analytics': 'noop',
'react-native-acme-scanner': {
modules: { AcmeScanner: './peach/AcmeScanner.ts' },
views: { AcmeScannerView: './peach/AcmeScannerView.tsx' },
},
},
})
  • +
    A whole-package 'noop' needs no names: Peach reads them from the installed package.
  • +
    A module file default-exports the native module object. It answers TurboModuleRegistry, legacy NativeModules and Expo’s requireNativeModule alike.
  • +
    A view file default-exports the component that renders in the native view’s place. An Expo view is Module_View, as in requireNativeView('Module', 'View').
  • +
    Your app’s own native code goes under the package it lives in: a local Expo module’s or workspace package’s name, or your app’s name.

Publish a stub

A stub anyone installs is an ordinary npm package named for the package it answers: peach-stub-<package>, or @your-scope/peach-stub-<package>. A scoped target uses the DefinitelyTyped encoding, so @acme/scanner becomes peach-stub-acme__scanner. Its package.json carries the same shape under peach:

{ "name": "peach-stub-react-native-acme-scanner", "version": "1.0.0", "files": ["*.ts", "*.tsx"], "peach": { "versions": ">=2 <4", "modules": { "AcmeScanner": "./AcmeScanner.ts" }, "views": { "AcmeScannerView": "./AcmeScannerView.tsx" } } }

An app that lists it in dependencies or devDependencies gets it with no config line. versions is optional; when the installed package is outside it, Peach does not apply the stub, since it was written for another native contract, and peach doctor says which versions it covers. A library can also answer for itself with the same peach field in its own package.json.

When more than one answers a name, the app’s peach.config.ts wins, then a stub package, then the library’s own field, then Peach’s builtin support. peach doctor shows who answers each package and marks stub packages as third-party: community code Peach has not checked against the real native side.

A native view stub

An app view registered as requireNativeComponent('RNBalanceLabel') in the app’s own package my-app:

defineConfig({
packages: {
'my-app': { views: { RNBalanceLabel: './peach/BalanceLabel.tsx' } },
},
})
// peach/BalanceLabel.tsx
import { Text } from 'react-native'
export default function BalanceLabel({ value }: { value: number }) {
return <Text>{value.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</Text>
}

The component receives the props the app passes to the native view. The file may import react, react-native, peach-kit and the packages Peach bridges for the app; JSX needs no React import. Render what the native view shows on device, using the platform primitives peach-kit exports where one exists (a blur, for example), so the screen keeps its layout and content.

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. Peach’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 Peach’s base RN implementation instead.