React Native (Expo)¶
Mobile Expo Validation
Expo 管理ワークフロー で動作する ReScript ベースの React Native アプリである — メンテナンスすべき android/ や ios/ ディレクトリはなく、日常作業に Xcode/Android Studio の前提条件もなく、単一の expo start コマンドで iOS / Android / web を 1 つのターミナルから駆動できる。ReScript コンポーネントは genType を通じて JS エントリポイントに公開されるため、外部に公開する App.tsx は 1 行の再エクスポートのままでよい。
ネイティブのビルドパイプラインを抱えずにモバイルアプリをリリースしたいチームは本テンプレートを選ぶ。すでに android/app/build.gradle への直接アクセスやカスタム CocoaPods が必要な場合は、代わりに React Native (Community CLI) テンプレートに進むこと — bare ワークフローをカバーしている。
生成内容¶
my-project/
├── rescript.json # JSX + genType enabled, depends on @rescript/react + validation lib
├── package.json # type: module-less (RN bundler eats CJS), expo + react-native deps
├── app.json # Expo manifest — name + slug substitute the project name
├── tsconfig.json # Extends expo/tsconfig.base, strict + noImplicitAny
├── App.tsx # one-liner: `import App from "./src/App.gen"; export default App;`
├── src/
│ ├── App.res # @genType — useState + TextInput + Button + FlatList todo list
│ ├── ReactNative.res # bindings for View / Text / TextInput / Button / FlatList / Style
│ ├── Validation.res # zod or sury — parseDraftTodo for the new-todo input
│ └── __tests__/App.test.mjs # vitest filesystem-only smoke test (does not import RN)
├── README.md # Bindings + Adding Screens + Project Layout sections
├── LICENSE # MIT, holder = project name
├── .nvmrc # Node 24
├── .gitignore # node_modules + ReScript output + .expo/, android/, ios/, *.tsbuildinfo
├── .editorconfig # 2-space indent, LF line endings
└── .github/
├── dependabot.yml # weekly npm updates
└── workflows/ci.yml # install + rescript build + vitest
android/ と ios/ は .gitignore に列挙されていることに注意。Expo が裏で管理している。これらに踏み込むのは expo prebuild や expo run:* 経由のみであり、生成されたツリーはチェックインすべきではない。
ウィザードオプション¶
オプション |
効果 |
|---|---|
Project name |
npm |
Package manager |
npm / pnpm / yarn / bun。 |
Validation library |
|
主要な依存¶
パッケージ |
用途 |
バージョン |
|---|---|---|
|
ReScript コンパイラ |
|
|
標準ライブラリ |
|
|
コンパイル済み |
|
|
React バインディング (JSX 有効化された |
|
|
React Native と共有する UI ライブラリ |
|
|
ネイティブ UI プリミティブ |
|
|
マネージドワークフロー CLI + ネイティブランタイム |
|
|
バリデーションバックエンド (フォーム入力パーサー) |
|
|
スモークテストランナー — pure-Node で、RN は決してロードしない |
|
|
|
|
|
|
|
|
genType が出力対象とする React サーフェスの型定義 |
|
主要なファイル¶
App.tsx¶
意図的に簡素である:
import App from "./src/App.gen";
export default App;
これは Expo のランタイムが起動するファイルである。実際の実装は src/App.res にある; genType がコンパイル時にその隣に src/App.gen.tsx を出力し、JS 層は型付きのコンポーネントを再エクスポートするだけである。ここにロジックを書いてはならない — 次回の再生成時に消去されてしまう。
src/App.res¶
対話的な TODO リスト。初日から実際に必要となるパターンを示している:
todos、draft、errorスライス用のReact.useState新規 TODO 入力用の
ReactNative.TextInput、送信用のReactNative.Buttonバリデーションゲート:
Validation.parseDraftTodo(draft)がエントリをtodosに加えるか、バナーとして表面化するかを判定する安定した
keyExtractorで TODO リストをレンダリングするReactNative.FlatList
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)
}
}
make に付与された @genType @react.component アノテーションが、App.tsx がインポートする App.gen.tsx を生成する。
src/ReactNative.res¶
App.res が使用する React Native コアコンポーネントセットへの最小限のバインディング:
モジュール |
対応先 |
補足 |
|---|---|---|
|
|
レイアウトコンテナ; |
|
|
テキストリーフ (RN は素の文字列のレンダリングを拒否する) |
|
|
制御された入力 ( |
|
|
|
|
|
|
|
インラインスタイルレコードビルダー |
|
各モジュールは @module("react-native") @react.component external make: (...) => React.element = "..." という同じ形に従う。ScrollView、Image、Pressable などはこのパターンをコピーして追加する。サードパーティのネイティブモジュールについては、"react-native" の代わりにパッケージ名でバインドする。
src/Validation.res¶
parseDraftTodo: string => result<draftTodo, string>。形状は zod でも sury でも同じであるため、呼び出し側は決して分岐しない。入力をトリムし、空のエントリと 120 文字を超えるエントリを拒否し、それ以外の場合はトリム済みの文字列を draftTodo レコードでラップして返す。
src/__tests__/App.test.mjs¶
スモークテストは意図的に App.res.mjs を インポートしない。コンパイル出力をロードすると React Native のバンドラーが引きずり込まれ、Vitest は素の Node でこれを実行できない。代わりに、テストはコンパイラが期待されるファイルを出力したかどうかだけをチェックする (existsSync(...))。「ビルドが完了したか」のカナリアとして扱うこと。振る舞いのテストではない。
コンポーネントロジックについては独自のフレームワーク下で別のテストを書くこと — RN コンポーネントテストは通常 jest-expo プリセットの Jest 上で実行される。
tsconfig.json¶
expo/tsconfig.base を継承し、"strict": true と "noImplicitAny": true を追加する。この strict のペアは genType が出力するコードを厳格に保つために必要である — 生成された .gen.tsx ファイルには防御的な any キャストが含まれておらず、すべてのバインディングが正確な型を持つことに依存している。
app.json¶
Expo のマニフェスト。最小限にスコープされている:
{
"expo": {
"name": "{{projectName}}",
"slug": "{{projectName}}",
"version": "1.0.0"
}
}
ストア提出の準備を始める段階で icon、splash、ios.bundleIdentifier、android.package などを追加する — 最新のセットについては Expo ドキュメントを参照のこと。
npm スクリプト¶
スクリプト |
説明 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
通常のセッションでは 2 つのターミナルを開いておく: ReScript ウォッチャー用の npm run res:dev と、Metro 用の npm start である。
画面の追加¶
初日にはルーターは存在しない — App.res がマウントされた唯一のコンポーネントである。複数画面のアプリへと成長させるには:
ルーターを選ぶ。
react-navigationはマネージド/bare 両ワークフローでデファクトスタンダードである; Expo ドキュメントがインストール手順をカバーしている (expo install @react-navigation/native @react-navigation/native-stackおよびプラットフォームの peer)。ReScript でバインドする。
ReactNative.resの隣に薄いNavigation.resを置く (同じ@module(...) @react.component external makeパターン) か、コミュニティのバインディングパッケージを取り込む。TODO 画面を移動する。
App.resのボディをsrc/Screens/Todos.resに引き上げ、App.resをそれを初期ルートとしてマウントするナビゲータコンテナに変える。新しい画面は
Todos.resの兄弟として追加する。 1 ファイル 1 画面を保つことで、genType がルートごとに 1 つの.gen.tsxを出力する — JS 層はナビゲータに触れることなくそれらをインポートできる。
README の "Adding Screens" セクションが同じフローを順を追って説明している。
コンポーネントバインディングの追加¶
src/ReactNative.res には App.res が実際に使用するコンポーネントしか含まれていない。別のもの (例: ScrollView) をバインドするには:
module ScrollView = {
@module("react-native") @react.component
external make: (
~horizontal: bool=?,
~contentContainerStyle: Style.t=?,
~children: React.element=?,
) => React.element = "ScrollView"
}
コミュニティモジュールでも同じパターンが使える — "react-native" をパッケージ名 ("react-native-reanimated"、"@shopify/flash-list" など) に置き換える。結果をモジュールでラップして make コンストラクタに名前空間を付与する。
2日目以降のレシピ¶
React コンポーネントの作成 — 同じ JSX パターンが適用される (
<div>をViewに置き換える)Import の最適化 — より多くのコンポーネントをバインドするにつれて
src/ReactNative.resを簡潔に保つデッドコードの検出 — 画面数が増えるにつれて有用になる
プロジェクトを開いた後の ReScript 側のエディタワークフローについては 機能概要 を参照のこと。
補足¶
マネージドワークフローのみ。 Expo Go と
expo run:*でほとんどのプロジェクトは賄えるが、Expo SDK エコシステムにまだないカスタムネイティブモジュールを必要とするものは、config プラグインか bare ワークフロー (React Native (Community CLI)) への移行のいずれかが必要となる。android/とios/は意図的に gitignore されている。 Expo がapp.json+expo.config.jsから再生成する。これらをコミットするとexpo prebuildのアップグレードができなくなる。スモークテストは意図的に無力である。
App.res.mjsを素の Node からインポートするとreact-nativeを引き込んでしまうため、コンパイラがファイルを出力したことだけをチェックしている。振る舞いのカバレッジが必要なら Jest +jest-expoでコンポーネントテストを追加すること。ここでは genType は必須であり、オプションではない。
App.tsxは./src/App.genをインポートする;.gen.tsxファイルはrescript.jsonで"genTypeConfig"が有効になっているからこそ出力される。これを無効化すると起動経路が壊れる。Vitest はソースのカナリア用であり、コンポーネント用ではない。
devDependenciesに見える Vitest は、スモークテスト (およびsrc/配下に追加する純粋なデータの ReScript テスト) を実行するために存在する。RN コンポーネントをレンダリングするようには設定されていない。バリデーションはフォームを守るのであって、ネットワークを守るのではない。 同梱の
parseDraftTodoはローカル状態のみを保護する。このアプリをバックエンドに接続する場合は、分解前に別のValidation.parse*関数でサーバーレスポンスをバリデートすること。Expo SDK / React Native のピンは一緒に動かす必要がある。
expoのバンプは通常マッチするreact-nativeマイナーバージョンを必要とする — 1 つのコミットでまとめて行い、peer 依存の警告がずれないようにnpm installを再実行すること。