React Native (Community CLI)¶
Mobile Bare Workflow Validation
A ReScript-powered React Native app on the Community CLI bare workflow — the path you take when you need direct access to the android/ Gradle project, the ios/ Xcode workspace, custom native modules in Kotlin/Swift, or any tooling Expo’s managed workflow doesn’t yet support. The template ships only the JavaScript / TypeScript / ReScript surface; the native projects themselves are produced by running @react-native-community/cli after the wizard finishes.
Pick this template if you have native-side requirements (a Kotlin SDK to wrap, a custom CocoaPod, a build flavor that diverges per environment) or if your team already lives in Android Studio / Xcode. If your needs sit comfortably inside Expo’s managed pipeline, the React Native (Expo) template will cost you less day-to-day overhead.
What You Get¶
my-project/
├── rescript.json # JSX + genType enabled, depends on @rescript/react + validation lib
├── package.json # react-native + community-cli + babel/metro configs
├── app.json # AppRegistry name + displayName
├── tsconfig.json # extends @react-native/typescript-config
├── index.js # RN entry — AppRegistry.registerComponent(appName, () => App)
├── App.tsx # one-liner: `import App from "./src/App.gen"; export default App;`
├── metro.config.js # extends @react-native/metro-config + adds "mjs" to sourceExts
├── babel.config.js # @react-native/babel-preset
├── src/
│ ├── App.res # @genType — useState + TextInput + Button + FlatList todo list
│ ├── ReactNative.res # bindings for View / Text / TextInput / Button / FlatList / Style
│ ├── NativeGreeting.res # example: NativeModules binding (bindings only — Kotlin/Swift impl by you)
│ ├── Validation.res # zod or sury — parseDraftTodo for the new-todo input
│ └── __tests__/App.test.mjs # vitest filesystem-only smoke test
├── README.md # CLI setup + Android Studio + Native Modules + Troubleshooting + Fallback
├── LICENSE # MIT, holder = project name
├── .nvmrc # Node 24
├── .gitignore # adds android/build/, ios/Pods/, *.hbc, *.keystore, *.apk, *.aab, *.ipa, …
├── .editorconfig # 2-space indent, LF line endings
└── .github/
├── dependabot.yml # weekly npm updates
└── workflows/ci.yml # install + rescript build + vitest
What is not in the box: the android/ and ios/ directories themselves. They are generated on first run by the Community CLI — see the next section.
Wizard Options¶
Option |
Effect |
|---|---|
Project name |
Becomes the npm |
Package manager |
npm / pnpm / yarn / bun. Sets |
Validation library |
|
Key Dependencies¶
Package |
Purpose |
Version |
|---|---|---|
|
ReScript compiler |
|
|
Standard library |
|
|
Runtime stubs imported by compiled |
|
|
React bindings (JSX-enabled |
|
|
UI library shared with React Native |
|
|
Native UI primitives |
|
|
Validation backend (form input parser) |
|
|
CLI used to scaffold |
|
|
Babel preset RN bundlers expect |
|
|
Default Metro config we extend in |
|
|
TS config base for |
|
|
Smoke test runner — pure-Node, never loads RN |
|
|
Coverage provider for |
|
|
Required for |
|
|
Type definitions for the React surface genType emits against |
|
Generating the Native Projects¶
The wizard does not run @react-native-community/cli for you. After the project is generated and dependencies are installed, run:
<your install command>
npx @react-native-community/cli init-android
npx @react-native-community/cli init-ios # macOS only
If init-android / init-ios are unavailable in your CLI revision, use the upstream template as a fallback:
npx @react-native-community/cli@latest init tmp
# then copy tmp/android (and tmp/ios) into this project
Prerequisites:
Target |
Toolchain |
|---|---|
Android |
JDK 21, Android Studio, Android SDK + NDK, watchman (macOS/Linux) |
iOS |
macOS, Xcode 16+, CocoaPods |
The README ships expanded versions of these instructions and substitutes your chosen package manager into the command lines.
Key Files¶
App.tsx¶
Intentionally trivial — an inline string in the generator, not loaded from a resource file:
import App from "./src/App.gen";
export default App;
AppRegistry (in index.js) calls () => App and does not invoke any factory. genType emits the matching App.gen.tsx next to src/App.res during compilation.
index.js¶
The native-side entry point. Standard React Native bootstrap:
import { AppRegistry } from "react-native";
import App from "./App";
import { name as appName } from "./app.json";
AppRegistry.registerComponent(appName, () => App);
metro.config.js¶
The single non-default piece of bundler configuration: appending "mjs" to Metro’s sourceExts so it picks up ReScript-compiled output.
const { getDefaultConfig, mergeConfig } = require("@react-native/metro-config");
const defaultConfig = getDefaultConfig(__dirname);
const config = {
resolver: {
sourceExts: [...defaultConfig.resolver.sourceExts, "mjs"],
},
};
module.exports = mergeConfig(defaultConfig, config);
If you ever see a Module not found error pointing at a .res.mjs file, this resolver entry is the first thing to verify.
babel.config.js¶
Single line of config:
module.exports = { presets: ["module:@react-native/babel-preset"] };
ReScript compiles .res to standards-compliant ES modules — no Babel transform of ReScript output is needed; this preset only services the JS / TS layer.
src/App.res¶
The interactive todo list. Identical to the Expo template’s demo — useState slices for todos, draft, error; validation gate before append; FlatList for render — and uses the same @genType @react.component annotations so genType emits App.gen.tsx.
let addTodo = () => {
switch Validation.parseDraftTodo(draft) {
| Error(message) => setError(_ => Some(message))
| Ok({text}) =>
let nextId = todos->Array.reduce(0, (max, t) => t.id > max ? t.id : max) + 1
setTodos(prev => prev->Array.concat([{id: nextId, text}]))
setDraft(_ => "")
setError(_ => None)
}
}
src/ReactNative.res¶
Same minimal binding set as the Expo template: View, Text, TextInput, Button, FlatList, plus a Style.make helper. Each module follows the @module("react-native") @react.component external make: (...) => React.element = "..." pattern; copy it to bind more components, or use a third-party package name in place of "react-native" for community modules.
src/NativeGreeting.res¶
The reason most teams pick the bare workflow: a worked example of binding a custom Kotlin/Swift NativeModule.
module NativeGreeting = {
@module("react-native") @scope("NativeModules")
external module_: {"greet": string => promise<string>} = "NativeGreeting"
let greet = (name: string): promise<string> => module_["greet"](name)
}
This file contains bindings only. The Kotlin (Android) or Swift (iOS) implementation must be added separately under android/app/src/main/java/... or ios/. The template intentionally does not ship the native side — it varies per platform and per minimum-OS target, and bundling a stale example would be more harmful than helpful. Follow the official RN guides:
src/Validation.res¶
parseDraftTodo: string => result<draftTodo, string>. Same shape and behavior as the Expo template — trims input, rejects blanks, rejects entries longer than 120 characters, otherwise hands back a draftTodo record.
src/__tests__/App.test.mjs¶
Filesystem-only smoke test — checks that the compiled artifacts exist via existsSync. Importing App.res.mjs from plain Node would pull in react-native, which Vitest can’t run. Treat this as a build canary; use Jest + jest-react-native for behavior tests.
tsconfig.json¶
Extends @react-native/typescript-config with "strict": true and "noImplicitAny": true. The strict pair keeps the genType-emitted .gen.tsx files honest.
.gitignore¶
The bare workflow needs more aggressive ignores than the Expo flavor: build outputs (android/build/, android/app/build/, ios/build/, ios/Pods/), gradle caches (android/.gradle/), local Android config (android/local.properties), Hermes bytecode (*.hbc), signing material (*.keystore, ios/.xcode.env.local), build artifacts (*.apk, *.aab, *.ipa), and TypeScript incremental info (*.tsbuildinfo).
Note that android/ and ios/ themselves are not ignored — once you generate them you commit the configs (build.gradle, Podfile, Info.plist, etc.) so the team builds reproducibly.
npm Scripts¶
Script |
Description |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
A typical session uses three terminals: npm run res:dev (ReScript watcher), npm start (Metro), and npm run android / npm run ios (or Android Studio / Xcode for the native build).
Day-Two Recipes¶
Create a React Component — same JSX patterns apply (substitute
Viewfor<div>)Optimize Imports — keep
src/ReactNative.resandsrc/NativeGreeting.reslean as bindings growFind Dead Code — useful as the screen + native-module surface grows
For ReScript-side editor workflows once the project is open, see Feature Overview.
Notes¶
Native projects are generated separately. The wizard ships only the JS/TS + ReScript surface. Run
npx @react-native-community/cli init-android/init-iosafter the wizard, or use the upstream template fallback — see “Generating the Native Projects” above.Open
android/, not the project root, in Android Studio. Gradle expects to be the IDE root. Set Gradle JDK to 21 underFile > Settings > Build, Execution, Deployment > Build Tools > Gradle. Metro must already be running before you hitRun > Run 'app'.metro.config.jsis the single non-default file. ThemjssourceExt is what makes ReScript output resolvable. If youmergeConfigfurther customizations, preserve that resolver entry or.res.mjsimports will start failing with “Module not found”.Native modules are bindings only.
NativeGreeting.resshows the ReScript half; you write the Kotlin / Swift half. The template stays neutral on which RN native-modules architecture you target (legacy bridge vs. TurboModules / Fabric) — the link in the file points at the legacy docs for clarity.@react-native-community/climoves quickly. Command names and flags drift between minor releases. If the README’s snippets diverge from the current CLI, fall back to the official docs at https://reactnative.dev; the ReScript half of the template (rescript.json,src/*.res,metro.config.js) is unaffected.Common stumbling blocks (also documented in the README’s Troubleshooting section):
Metro cannot reach the device:
adb reverse tcp:8081 tcp:8081Stale Metro cache: restart Metro with
--reset-cacheGradle errors after upgrading deps:
cd android && ./gradlew clean, then rebuildModule not found for
.res.mjs: confirmmetro.config.jsstill appendsmjstoresolver.sourceExts
The smoke test is intentionally toothless. Same constraint as the Expo flavor —
App.res.mjscannot be imported from plain Node. Use Jest with the appropriate RN preset for component-level tests.genType is mandatory. The boot path
index.js → App.tsx → src/App.genonly works becauserescript.jsonhas genType enabled. Disabling it breaksApp.tsximmediately.