React Native歴7年目のディレクトリ構成とソースコードとちょっとした豆知識

# 個人での開発や実務でよく使われている React Native構成

皆さんはクロスプラットフォームでのモバイルアプリケーションの開発に触れたことはありますか？

私は社会人1年目の頃にReactNativeと出会い、恋に落ち、何度も振り回された結果、気づけば結婚(本業)となっていました。

- スタートアップなどで既存プロダクトチームへジョイン
- ベンチャーでの新規プロジェクト立ち上げ
- 約6年続けている個人開発での技術選定

などなど、多くのReact Nativeプロジェクトを見てきましたが、最近はライブラリの選定が落ち着いてきたなという印象があるのでまとめておこうと思います。

もし「これは推奨しないよ！」や、「このライブラリの利用はどうですか？」など質問がありましたら、可能な限りお答えさせていただきますのでコメントや[X](https://twitter.com/nakapooooon)でご連絡ください🙋‍♂️

## まずは大枠から

### [TypeScript](https://www.typescriptlang.org/)

バックエンドとの疎通やライブラリで用意されてるプロパティの確認など、VSCodeなどのエディタが発展してなくてはならないものになりました。

最初は少し抵抗がある方もいるかもしれませんが、一度学習すれば他のオブジェクト指向型言語にも応用できる知識もあるので覚えて損はありません。

### [Expo](https://docs.expo.dev/)

#### `React　Native`は`Expo`を利用してこそ輝きます。

Expoには`Managed`と`Bare`と呼ばれるworkflowがありますが、
まずは`Managed workflow`を選択して、プロジェクトの途中でXcodeやAndroid Studioでの開発を行いたい場合には、Bare workflowに変更するのが無難かと思います。

逆はほとんど聞かないのですが[こちら](https://zenn.dev/ventus/articles/32bc78592d9c28)の記事で行っている方を見かけたので、もしExpoの進化や機能変更などで戻せる状況になった場合はメンテナンスコストが削減できるのでぜひお試しください。

#### SDKアップグレードへの追従時の注意点
SDKへの追従が追いついておらず、ビルドやライブラリが動かないなどは他の言語と同様に発生します。新しいSDKがリリースされた場合は数週間様子見することを推奨します。

Bawe workflowでネイティブ階層に手を加えている場合は、他言語やフレームワークと同様にXcodeやAndroid Studioでの対応が必要です。

#### 余談

先日[こちら](https://github.com/EvanBacon/pillar-valley)の[Evan Bacon](https://twitter.com/Baconbrix)さん(Expoの主力メンテナー)のソースコードとサービスを眺めていたところ、ネイティブ階層無しでiOSのウイジェットを実装されています。

今後Managed workflowの恩恵そのままで、必要に応じてネイティブ実装を誰でも・何らかの形でカスタマイズできるように進化していくかもしれません😳

## ライブラリなど

### [React Navigation](https://reactnavigation.org/)

React Nativeを実装する場合のルーティングで99%(?)利用するライブラリです。

[Expo Router](https://docs.expo.dev/router/introduction/)という`Next.js`のようなファイルルーティングが可能なライブラリも登場していますが、まだ参考記事が少ないのと`Expo Router`は`React Navigationのラッパー`であることから、まだプロジェクト単位での採用は行なっていません。

### [expo-localization](https://docs.expo.dev/versions/latest/sdk/localization/) & [i18n-js](https://github.com/fnando/i18n-js)

多言語対応などを行う際に利用することが多いです。
私の場合は型変換でストレスなく多言語化するために以下のように定義したものを`i18n-js`に読み込ませて動作させることが多いです。（英語力ゼロなので命名がポンコツなのは許してください）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/630810/ec0f00ec-ac59-e51a-4b60-9eadfc0fea2b.png)

### [React Native Reanimated](https://docs.swmansion.com/react-native-reanimated/)

アニメーションを実装する際に、ほぼ必ずと言っていいほど利用するライブラリです。
特徴としてはReact Nativeで実装したコードを`ネイティブ層でのアニメーション`として表示してくれるので高いパフォーマンスで動作が可能です。

非ネイティブ層でアニメーションを表示するライブラリもありますが、サービスの規模がスケールした時に備えて最初から導入するケースが多いです。

### [expo-dev-client](https://docs.expo.dev/develop/development-builds/introduction/)

Expoで開発をしていくとライブラリ内でネイティブ実装されているケースに必ずと言っていいほど遭遇します。
その場合はJavaScriptの環境しかなく実行が行えません。こちらを利用したビルドファイルで、Expo Goのようにローカルネットワーク経由でホットリロードなども失われずに開発を続けることが可能です。

- シミュレーターであればシミュレーターにドラッグ&ドロップでインストール
- iOSであればAirDropで送信
- Androidの場合はドライブにあげたり

など簡単に端末へのインストールも可能なので手軽に利用できます。

### 状態管理

こちらに関してはReact経験者の場合は、既に利用されたことのあるものを採用するのが無難かと思います。
個人的にはずっとRecoilを利用してきましたが、メインのメンテナー不在問題も解決されなさそうなので今後は[Jotai](https://jotai.org/)を試そうと考えています。

## ディレクトリ構成

これがベスト！みたいなものはありませんが、大体このような構成になります。
基本的にプロジェクトの意向に合わせつつ、個人開発の場合は脳死で進行できるよう以下のようにすることが多いです。

```typescript
root/
 L src
   ├ assets // アイコンなど画像系
   ├ atoms // ZustangやJotaiなど状態管理系
   ├ components // 画面内部品
   ├ constnts // 定数
   ├ dao // データアクセス関連
   ├ hooks // Hooks関連
   ├ localize // 多言語対応関連
   ├ mocks // テストやデバッグに使うモックデータ
   ├ navigation // 画面遷移関連（expo-routerはv1の頃から様子見）
   ├ purchases // 課金関連
   ├ screens // 画面
   ├ types // 型など
   ├ utils // その他横断的に利用する処理など
 ├ package.json
 └ app.json
```

## 各種設定周り

2ヶ月ほど運用している以下サービスの場合は以下のようになります。

https://bsc.nakapon.jp/ja

### package.json

後述するapp.jsonで不都合があるため`gen:config:xxx`というコマンドを[ts-node](https://github.com/TypeStrong/ts-node)で実行して、app.jsonをローカルで生成して動的に環境を変更できるようにしています。

ネイティブの機能が含まれたライブラリを複数利用するために開発は[expo-dev-client](https://docs.expo.dev/develop/development-builds/introduction/)を利用しています。

また`build:xxx:yyy`などのビルドを行う場合は無料枠を超えてしまう可能性があるため、基本的にEAS Buildのクラウドビルドは使わずにローカルで行うように `--local` フラグを追加しています。

```json
{
  "name": "xxx",
  "version": "1.0.0",
  "main": "node_modules/expo/AppEntry.js",
  "scripts": {
    "dev": "yarn gen:config:dev && expo start --dev-client",
    "devprod": "yarn gen:config:prod && expo start --dev-client",
    "android": "expo start --android",
    "ios": "expo start --ios",
    "web": "expo start --web",
    "gen:config:dev": "APP_ENV=dev ./node_modules/.bin/ts-node ./generateConfig.ts",
    "gen:config:devprod": "APP_ENV=prod DEBUG=ON ./node_modules/.bin/ts-node ./generateConfig.ts",
    "gen:config:stg": "APP_ENV=stg ./node_modules/.bin/ts-node ./generateConfig.ts",
    "gen:config:prod": "APP_ENV=prod ./node_modules/.bin/ts-node ./generateConfig.ts",
    "build:dev:ios": "yarn gen:config:dev && eas build --profile=development --platform=ios --local",
    "build:dev:android": "yarn gen:config:dev && eas build --profile=development --platform=android --local",
    "build:devprod:ios": "yarn gen:config:devprod && eas build --profile=devprod --platform=ios --local",
    "build:devprod:android": "yarn gen:config:devprod && eas build --profile=devprod --platform=android --local",
    "build:simulator": "yarn gen:config:dev && eas build -p ios --profile simulator --local",
    "build:stg:ios": "yarn gen:config:stg && eas build --profile=preview --platform=ios --local",
    "build:stg:android": "yarn gen:config:stg && eas build --profile=preview --platform=android --local",
    "build:prod:ios": "yarn gen:config:prod && eas build --profile=production --platform=ios --local",
    "build:prod:android": "yarn gen:config:prod && eas build --profile=production --platform=android --local",
    "eject": "expo eject",
    "lint": "eslint --ext .js,.ts .",
    "test": "TZ=utc jest",
    "test:watch": "yarn test --watch --silent=false --verbose false",
    "test:clear": "yarn run tsc --build --clean"
  },
  "jest": {
    "preset": "jest-expo"
  },
  "dependencies": {
    "@react-native-async-storage/async-storage": "1.18.2",
    "@react-native-community/datetimepicker": "7.2.0",
    "@react-native-community/slider": "4.4.2",
    "@react-native-firebase/analytics": "^18.6.1",
    "@react-native-firebase/app": "^18.6.1",
    "@react-native-firebase/auth": "^18.6.1",
    "@react-native-firebase/firestore": "^18.6.1",
    "@react-native-firebase/functions": "^18.6.1",
    "@react-navigation/bottom-tabs": "^6.5.11",
    "@react-navigation/native": "^6.1.9",
    "@react-navigation/native-stack": "^6.9.17",
    "dayjs": "^1.11.3",
    "expo": "^49.0.0",
    "expo-apple-authentication": "~6.1.0",
    "expo-asset": "~8.10.1",
    "expo-auth-session": "~5.0.2",
    "expo-build-properties": "~0.8.3",
    "expo-constants": "~14.4.2",
    "expo-crypto": "~12.4.1",
    "expo-dev-client": "~2.4.8",
    "expo-device": "~5.4.0",
    "expo-file-system": "~15.4.5",
    "expo-font": "~11.4.0",
    "expo-haptics": "~12.4.0",
    "expo-image": "~1.3.5",
    "expo-keep-awake": "~12.3.0",
    "expo-linear-gradient": "~12.3.0",
    "expo-linking": "~5.0.2",
    "expo-localization": "~14.3.0",
    "expo-media-library": "~15.4.1",
    "expo-splash-screen": "~0.20.5",
    "expo-status-bar": "~1.6.0",
    "expo-store-review": "~6.4.0",
    "expo-system-ui": "~2.4.0",
    "expo-updates": "~0.18.19",
    "expo-web-browser": "~12.3.2",
    "fast-sort": "^3.4.0",
    "i18n-js": "^4.3.2",
    "react": "18.2.0",
    "react-content-loader": "^6.2.1",
    "react-dom": "18.2.0",
    "react-native": "0.72.6",
    "react-native-admob-native-ads": "^0.6.6",
    "react-native-calendars": "^1.1303.0",
    "react-native-chart-kit": "^6.12.0",
    "react-native-circular-progress": "^1.3.9",
    "react-native-gesture-handler": "~2.12.0",
    "react-native-get-random-values": "~1.9.0",
    "react-native-google-mobile-ads": "^12.1.1",
    "react-native-keyboard-aware-scroll-view": "^0.9.5",
    "react-native-logs": "^5.0.1",
    "react-native-modal-datetime-picker": "^17.1.0",
    "react-native-popover-view": "^5.1.8",
    "react-native-purchases": "^6.7.0",
    "react-native-reanimated": "~3.3.0",
    "react-native-safe-area-context": "4.6.3",
    "react-native-screens": "~3.22.0",
    "react-native-share": "^10.0.1",
    "react-native-svg": "13.9.0",
    "react-native-toast-message": "^2.2.0",
    "react-native-url-polyfill": "^2.0.0",
    "react-native-view-shot": "3.7.0",
    "react-native-web": "~0.19.6",
    "react-native-webview": "13.2.2",
    "recoil": "^0.7.4",
    "telejson": "^6.0.8"
  },
  "devDependencies": {
    "@babel/core": "^7.20.0",
    "@types/jest": "^29.5.11",
    "@types/react": "~18.2.14",
    "@types/xdate": "^0.8.35",
    "@typescript-eslint/eslint-plugin": "^6.7.0",
    "@typescript-eslint/parser": "^6.7.0",
    "babel-plugin-transform-remove-console": "^6.9.4",
    "eslint": "^8.49.0",
    "eslint-plugin-prettier": "^5.0.0",
    "eslint-plugin-react": "^7.33.2",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-native": "^4.1.0",
    "jest": "^29.2.1",
    "jest-expo": "^49.0.0",
    "react-test-renderer": "17.0.2",
    "ts-jest": "^29.1.1",
    "ts-node": "^10.9.1",
    "typescript": "^5.1.3"
  },
  "private": true,
  "resolutions": {
    "@types/react": "~17.0.21"
  }
}
```

### app.json

複数の環境を切り分ける際には`app.config.ts`などを利用した記事などが多いのですが、アプリ内広告である`react-native-google-mobile-ads`などで不都合があったのでpackage.jsonに記載の通り、コマンドで自動生成して運用することが多いです。

```json
{
  "name": "xxx",
  "displayName": "xxx",
  "expo": {
    "version": "1.2.0",
    "platforms": [
      "ios",
      "android"
    ],
    "orientation": "portrait",
    "userInterfaceStyle": "automatic",
    "name": "xxx",
    "slug": "xxx",
    "scheme": "xxx",
    "icon": "./src/assets/images/icon.png",
    "splash": {
      "image": "./src/assets/images/splash-light.png",
      "resizeMode": "contain",
      "backgroundColor": "#ffffff"
    },
    "updates": {
      "enabled": true,
      "fallbackToCacheTimeout": 0,
      "url": "https://u.expo.dev/{projectId}"
    },
    "assetBundlePatterns": [
      "**/*"
    ],
    "jsEngine": "hermes",
    "ios": {
      "buildNumber": "21",
      "supportsTablet": false,
      "bundleIdentifier": "example.com",
      "googleServicesFile": "./GoogleService-Info.plist",
      "requireFullScreen": true,
      "appStoreUrl": "https://apps.apple.com/app/id9999999999",
      "isTabletOnly": false,
      "config": {
        "usesNonExemptEncryption": false
      },
      "infoPlist": {
        "CFBundleAllowMixedLocalizations": true
      },
      "splash": {
        "image": "./src/assets/images/splash-light.png",
        "resizeMode": "contain",
        "backgroundColor": "#ffffff",
        "dark": {
          "image": "./src/assets/images/splash-dark.png",
          "resizeMode": "contain",
          "backgroundColor": "#000"
        }
      }
    },
    "android": {
      "package": "xxx",
      "versionCode": 21,
      "playStoreUrl": "https://play.google.com/store/apps/details?id=example.com",
      "config": {},
      "adaptiveIcon": {
        "foregroundImage": "./src/assets/images/adaptive-icon.png",
        "backgroundColor": "#000"
      },
      "googleServicesFile": "./google-services.json",
      "permissions": [
        "INTERNET",
        "VIBRATE"
      ],
      "splash": {
        "image": "./src/assets/images/splash-light.png",
        "resizeMode": "contain",
        "backgroundColor": "#ffffff",
        "dark": {
          "image": "./src/assets/images/splash-dark.png",
          "resizeMode": "contain",
          "backgroundColor": "#000"
        }
      }
    },
    "web": {
      "config": {
        "google": {
          "expoClientId": "",
          "iosClientId": "",
          "androidClientId": ""
        }
      }
    },
    "locales": {
      "ja": "./src/localize/ja.json",
      "en": "./src/localize/en.json",
      "ko": "./src/localize/ko.json"
    },
    "extra": {
      "appEnv": "prod",
      "eas": {
        "projectId": "yyy"
      }
    },
    "plugins": [
      [
        "@react-native-firebase/app"
      ],
      [
        "expo-build-properties",
        {
          "android": {
            "minSdkVersion": 33
          },
          "ios": {
            "useFrameworks": "static"
          }
        }
      ],
      [
        "expo-media-library",
        {
          "photosPermission": "$(PRODUCT_NAME)が写真へのアクセスを求めています",
          "savePhotosPermission": "$(PRODUCT_NAME)が写真を保存することを求めています",
          "isAccessMediaLocationEnabled": false
        }
      ],
      [
        "react-native-admob-native-ads",
        {
          "androidAppId": "ca-app-pub-aaaaaaaaaaaaaaaa~bbbbbbbbbb",
          "iosAppId": "ca-app-pub-aaaaaaaaaaaaaaaa~bbbbbbbbbb",
          "facebookMediation": false
        }
      ]
    ],
    "runtimeVersion": {
      "policy": "sdkVersion"
    }
  },
  "react-native-google-mobile-ads": {
    "android_app_id": "ca-app-pub-aaaaaaaaaaaaaaaa~bbbbbbbbbb",
    "ios_app_id": "ca-app-pub-aaaaaaaaaaaaaaaa~bbbbbbbbbb",
    "delay_app_measurement_init": true,
    "user_tracking_usage_description": "サービス運営維持のため広告を表示させていただいています。個人の特定につながることはありませんのでご安心ください。"
  }
}
```

## 最後に

React NativeがGoogleのサジェストにディスられてた時代から、React需要と共に海外でガヤガヤされている今までずっと触り続けてきましたが、今も昔もずっと大好きなフレームワークです。

特にExpoへの理解が深まるほどにReact Nativeでの技術的な問題解決及び、開発体験の向上も高めていくことができたので、初期の頃たくさん苦しんだ甲斐があったなと感じます。

FlutterやNext.jsなど定期的に浮気はしていますが、これからもReact NativeとExpoもしくは自身の技術力が廃れていくまで、触れていきたいなと思っています。

もしお困りごとがあれば、なんでも知ってる限りはお答えするのでいつでもご連絡ください。

Xアカウント
https://twitter.com/nakapooooon

BSC (React Native + Expo)
https://bsc.nakapon.jp/ja

Knot (React Native + Expo)
https://www.konoyubitomare.app/