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 prebuildexpo run:* 経由のみであり、生成されたツリーはチェックインすべきではない。

ウィザードオプション

オプション

効果

Project name

npm nameapp.json の Expo name + slug、LICENSE のホルダー、および TODO リストの上に表示されるタイトル ({{projectName}} TODOs) になる

Package manager

npm / pnpm / yarn / bun。packageManager、README のインストール/実行スニペット、および CI キャッシュキーを設定する。注意: Expo CLI は 4 つすべてで動作するが、EAS Build は過去 npm/yarn を優先してきた — 本番に進む場合は最新の Expo ドキュメントを確認すること

Validation library

zodsury。同梱する src/Validation.res のバリアントと、dependencies に追加される依存を選択する

主要な依存

パッケージ

用途

バージョン

rescript

ReScript コンパイラ

TemplateVersions.RESCRIPT

@rescript/core

標準ライブラリ

TemplateVersions.RESCRIPT_CORE

@rescript/runtime

コンパイル済み .res.mjs がインポートするランタイムスタブ

TemplateVersions.RESCRIPT_RUNTIME

@rescript/react

React バインディング (JSX 有効化された rescript.json)

TemplateVersions.RESCRIPT_REACT

react

React Native と共有する UI ライブラリ

TemplateVersions.REACT

react-native

ネイティブ UI プリミティブ

TemplateVersions.REACT_NATIVE

expo

マネージドワークフロー CLI + ネイティブランタイム

TemplateVersions.EXPO

zod または sury

バリデーションバックエンド (フォーム入力パーサー)

TemplateVersions.ZOD / SURY

vitest (dev)

スモークテストランナー — pure-Node で、RN は決してロードしない

TemplateVersions.VITEST

@vitest/coverage-v8 (dev)

test:coverage のカバレッジプロバイダ

TemplateVersions.VITEST_COVERAGE_V8

typescript (dev)

App.tsx および genType が出力する .gen.tsx ファイルに必要

TemplateVersions.TYPESCRIPT

@types/react (dev)

genType が出力対象とする React サーフェスの型定義

TemplateVersions.REACT_TYPES

主要なファイル

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 リスト。初日から実際に必要となるパターンを示している:

  • todosdrafterror スライス用の 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 コアコンポーネントセットへの最小限のバインディング:

モジュール

対応先

補足

View

react-nativeView

レイアウトコンテナ; style + children を受け取る

Text

react-nativeText

テキストリーフ (RN は素の文字列のレンダリングを拒否する)

TextInput

react-nativeTextInput

制御された入力 (value + onChangeText)

Button

react-nativeButton

title + onPress を持つタップターゲット

FlatList

react-nativeFlatList

data / keyExtractor / renderItem を持つ仮想化リスト

Style.make

インラインスタイルレコードビルダー

~flex, ~padding, ~margin, ~backgroundColor

各モジュールは @module("react-native") @react.component external make: (...) => React.element = "..." という同じ形に従う。ScrollViewImagePressable などはこのパターンをコピーして追加する。サードパーティのネイティブモジュールについては、"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"
  }
}

ストア提出の準備を始める段階で iconsplashios.bundleIdentifierandroid.package などを追加する — 最新のセットについては Expo ドキュメントを参照のこと。

npm スクリプト

スクリプト

説明

start

expo start — Metro + Expo Dev Tools を起動する

android

expo start --android — 接続された Android エミュレータ/デバイス上でビルドして起動する

ios

expo start --ios — iOS シミュレータ/デバイス上でビルドして起動する (macOS のみ)

test

vitest run — ソースのスモークテストを実行する

test:coverage

vitest run --coverage — 同上、v8 カバレッジ付き

res:build

rescript — ワンショットの ReScript コンパイル

res:dev

rescript -w — 保存時に再コンパイル

res:clean

rescript clean — 生成された .res.mjs.gen.tsx を削除

通常のセッションでは 2 つのターミナルを開いておく: ReScript ウォッチャー用の npm run res:dev と、Metro 用の npm start である。

画面の追加

初日にはルーターは存在しない — App.res がマウントされた唯一のコンポーネントである。複数画面のアプリへと成長させるには:

  1. ルーターを選ぶ。 react-navigation はマネージド/bare 両ワークフローでデファクトスタンダードである; Expo ドキュメントがインストール手順をカバーしている (expo install @react-navigation/native @react-navigation/native-stack およびプラットフォームの peer)。

  2. ReScript でバインドする。 ReactNative.res の隣に薄い Navigation.res を置く (同じ @module(...) @react.component external make パターン) か、コミュニティのバインディングパッケージを取り込む。

  3. TODO 画面を移動する。 App.res のボディを src/Screens/Todos.res に引き上げ、App.res をそれを初期ルートとしてマウントするナビゲータコンテナに変える。

  4. 新しい画面は 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日目以降のレシピ

プロジェクトを開いた後の 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 を再実行すること。