はじめに
私は毎日 Next.js、Tailwind、コンポーネントライブラリ、デザインシステムを書く React 開発者で、そちらの世界は完全に馴染みがありました。ネイティブはそうではなく、何年もその差は途方もなく大きく感じられました。モバイルアプリを作るたびに、Xcode、Swift、プロビジョニングプロファイル、シミュレータのセットアップ、App Store のルールを想像し、「Hello World」にたどり着く前に2週間が消えるイメージしか浮かばなかったので、多くの React Web 開発者と同じように Web に留まっていました。
しかし、1週間で本物のモバイルアプリを作りました。チュートリアル用のアプリでも、カウンターでも、また別の todo リストでもありません。本物の iOS ネイティブアプリを作りました。名前は Sun Buddy — かわいいアニメーションの太陽マスコット、位置情報に基づくリアルタイム UV トラッキング、ハプティクス(触覚フィードバック)、毎日のローカル通知、永続的な進捗保存、日没時には眠る夜間状態を備えています。
要約
- 使用ツール: Expo、Claude Code、Expo Skills
- 結論: アプリ開発者になる必要はなく、既に持っている React スキルの "ネイティブな端(エッジ)" を学べばよかった
以下はエンドツーエンドでの旅程:React から何が移行できたか、何が移行しなかったか、Expo がどこでネイティブを親しみやすくしたか、そしてどこでプラットフォームの現実が顔を出したか。
アイデア: Sun Buddy
アプリはネイティブ開発を正しくテストできるほど十分に"本物"である必要がありました。単なる見せかけのウェブページにしたくなかったので、位置情報、ハプティクス、通知、ネイティブアニメーション、ローカルストレージ、実機でのビルドなど、実際のデバイス機能に触れたかった。そのため、日光暴露トラッカーを選びました。
Sun Buddy は、推奨される屋外日光の目標達成を支援する日課アプリで、実際の位置情報から取得した UV インデックスでトラッキングします。アイデアはシンプル:毎日屋外で少し日光を浴びるべきだが、多くの人はそれをしていない。マスコットの Sunny は進捗に応じて感情的に反応します。
Sunny の状態:
- 0%: 眠そう(drowsy)
- スタート時: 興味津々(curious)
- 半分到達時: ハッピー(happy)
- 目標達成時: とても輝いている(absolutely radiant)
- 日没時: 睡眠状態(asleep)
少し変で、少し可愛い。実際に自分の電話に置いておきたい種類のアプリです。
Sun Buddy は意図的に小さく作りました。目的は、位置情報の権限、デバイス API、ハプティクス、ローカル通知、永続状態、アニメーション、アプリ設定、そして実機ビルドなど、実際のネイティブ領域を通ることでした。これらのパターンは太陽マスコット特有ではなく、フィットネスアプリ、習慣トラッカー、現地作業ツール、旅行アプリ、社内ダッシュボード、配達ワークフローなど、電話上で自然に感じられる必要があるあらゆるアプリに当てはまります。
セットアップ: Expo を最初に、Claude Code を次に
この体験で最も重要だったのは「AI を使った」ということではなく、既に持っている React のメンタルモデルを使ってネイティブアプリを作れたこと、そしてそこに Expo が効いたことです。Expo はアプリ構造、ルーティング、デバイス API、ビルドパス、そして「React を知っている」から「これが私の電話で動いている」への橋渡しをしてくれました。
Claude Code はペアリングループ(対話的なコード作成)を提供し、Expo Skills がそのループをより有用にしました。この違いは重要です。Claude Code 単体でもコードを書く手助けはできますが、ネイティブ開発には権限、config プラグイン、EAS Build プロファイル、App Store の制約、ネイティブモジュールのサポートなど、一般的な助言では足りない細かい点が多くあります。これらは、古い、あるいは曖昧なガイダンスが時間を無駄にする正にその箇所です。
Expo Skills はワークフローに Expo 固有のコンテキストをもたらしました。ネイティブ固有の質問をしたとき、その差が最も分かりました。一般的な AI はコンポーネントの書き方を教えてくれますが、難しかったのは次のようなことでした:
- これは通常の Expo Go フローにすべきか、それとも dev client にすべきか?
- どこにこの権限を設定する必要があるか?
- これはランタイムライブラリなのか、config プラグインなのか、あるいは両方か?
- 本物の iPhone ビルドへの正しい道は?
- シミュレータテストから TestFlight に移すと何が変わるか?
ここで Expo Skills が役立ちました—Expo 固有のコンテキストを持ったペアリングループを提供したのです。
私は Claude Code に Skills を追加しました:
/plugin add https://github.com/expo/skills.git
/reload-plugins
使った Expo Skills(4つ):
- building-native-ui — Expo Router のパターン、ネイティブ UI 構造、Apple HIG ガイダンス
- expo-deployment — EAS Build、TestFlight、App Store、資格情報
- expo-dev-client — 開発ビルドとネイティブモジュールのサポート
- expo-app-design — ネイティブアプリ設計パターン
これにより Claude Code は単なる汎用コーディングアシスタントというより、Expo エコシステムを理解する共同作業者のように感じられました。
驚き: React スキルはほとんどそのまま移行できた
最大のメンタルシフトはこれでした。コンポーネントのコードはただの React でした—useState、useEffect、カスタムフック、JSX、コンポーネントの合成、条件付きレンダリング、データフェッチ、ローディングとエラー状態の管理。すべてが使えました。プリミティブが変わるだけでした: div の代わりに View、p の代わりに Text、button の代わりに Pressable。しかしメンタルモデル自体はまったく馴染みのあるものでした。
useUVIndex.ts や useSunTracking.ts は、Web アプリ用のフックを書くのと同じように書きました: データをフェッチし、状態を管理し、コンポーネントに値を返す。レイアウトモデルも馴染みのある flexbox でした。つまり、Web のすべてが完全に移行するわけではありませんが、コアとなる React の直感は思ったより多く移行しました。
自動では移行しなかった部分(ネイティブの "端")
周辺のレイヤーがネイティブを違うものにしていました。コンポーネントモデルや React 部分ではなく、ネイティブ特有の端(エッジ)です。
例:
- 権限プロンプト
- デバイス API
- ビルド時の設定
- ネイティブモジュール
- シミュレータの状態
- 開発ビルド
- コード署名
- App Store の制約
- 実機テスト
これらが Expo と Expo Skills なしでは間違った方向に進んだであろう箇所です。例えば、ネイティブでの位置情報は navigator.geolocation ではありません。expo-location を使う場合、まずフォアグラウンド権限をリクエストし、結果をチェックし、拒否を適切にハンドルしてから位置を取得する必要があります。ロジックの形は馴染み深いですが、プラットフォームの期待は異なります—難しくはないが正しい順序を知っておく必要があります。
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== "granted") {
return;
}
const location = await Location.getCurrentPositionAsync({});
config の扱いも同様でした。Web では app.json のようなファイルをあまり意識しませんが、Expo ではこのファイルが重要です。expo-router、expo-location、expo-notifications のようなパッケージは単なるランタイムライブラリではなく、ネイティブ設定が必要で、app config に plugin エントリが必要になることがあります。私のアプリでは次のような plugin エントリが必要でした:
"plugins": [
"expo-router",
"expo-location",
[
"expo-notifications",
{ "icon": "./assets/notification-icon.png" }
]
]
ここがまさに Expo 固有のコンテキストが役立ったところです。これがなければ、理にかなった React コードを書いた後に、コンポーネントとは無関係なネイティブの振る舞いをデバッグして時間を浪費していたでしょう。
追加したネイティブ機能
アプリ構造が整ったら、実際にネイティブらしさを出す機能を追加しました。
-
位置情報: デバイスの GPS 座標を取得するために expo-location を使用し、Open-Meteo API に渡して現在の UV インデックスを取得しました(API キー不要、バックエンド不要)。これにより、ユーザーに都市名を入力させるのではなく、デバイスが既にどこにいるかを知っているという点で Web プロジェクトと明確に違いました。
-
ハプティクス: これが最小の機能でありながら最大の違いを生みました。expo-haptics を使い、サンセッション開始時に軽いインパクトフィードバック、100% 達成時に成功フィードバックを与えました。コードは小さい:
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
そして:
await Haptics.notificationAsync(
Haptics.NotificationFeedbackType.Success
);
効果は即時でした。タップ、バウンス、ハプティクス。突然、それは React 画面のふりをしたものではなく、電話に本当に属するものに感じられました。Web 開発者は通常ハプティクスを考慮しません(現状では有意義な Web 対応がないため)が、ネイティブでは2行のコードでインタラクション全体の感触を変えられます。
-
通知: 毎日のリマインダーには expo-notifications を使い、文言はマスコットの性格に合わせて少し変わった感じにしました: "Sunny is waiting for you outside. Probably." これはキャラに合っていると感じました。
-
UI とアニメーション: UI は @shopify/react-native-skia で描き、react-native-reanimated でアニメーション(バウンス、点滅、状態遷移)を実装しました。Framer Motion を使ったことがあるならメンタルモデルは馴染みやすく、結果は期待以上に滑らかでした。
-
永続化: 日次の進捗は @react-native-async-storage/async-storage を使い、非同期の localStorage のように感じました。
-
フォントとアイコン: expo-font で Nunito を、@expo/vector-icons をタブアイコンに使用しました。
これらはモバイル開発を一から学んだ感じはなく、むしろ React を使って Web では触れないデバイスの領域に到達している感覚でした。
実際に壊れた箇所(トラブル)
多くは予想より速く動きましたが、いくつかは壊れました。
Skia の Web でのクラッシュ
開発中にネイティブと Web 両方で動かしたかったため、ネイティブ版は Skia を使い、Web は CanvasKit と WebAssembly に依存します。CanvasKit の準備が整う前に Skia.Path.Make() を呼んでしまい、Web ビルドが即座にクラッシュしました。バグは見れば理解できましたが、慣習的な React Native の直し方を知らなかったのが問題でした。
修正はプラットフォームごとのファイル分割でした:
- components/SunBuddy.native.tsx # フル Skia 実装
- components/SunBuddy.web.tsx # SVG フォールバック
Metro は .native.tsx を iOS、.web.tsx を Web 用に自動解決します。このパターンは私にとって新しいものでした。Web ならランタイムチェックや遅延読み込みに頼るところですが、React Native ではプラットフォーム別ファイルが一級の慣習です。デバッグの直感は移行しましたが、ネイティブのパターン知識は常に移行しなかったため、Expo Skills がそのギャップを埋めてくれました。
古い Metro バンドラ
react-native-reanimated を追加した後、シミュレータを開いたらアプリがクラッシュしました。エラーはあまり親切でなく、15分ほど自分の設定ミスだと思って調べました。実際の解決は Metro をクリアすることでした:
npx expo start
古いバンドルがまだ提供されており、Reanimated の Babel プラグインが正しく登録されていなかったのです。これは Web 開発で慣れている Vite のように自動で再起動してくれるわけではないので、エディタ以外の状態が残ることがある点が慣れの課題でした。
夜間(nighttime)バグ
これが一番好きなバグです。技術的な問題というよりプロダクトの問題でした。夜の 21:00 にテストしていて、外は真っ暗なのに "start sun session" をタップしました。Sunny はバウンスし、進捗は増え、アプリは喜んで太陽を浴びていると記録しました。アプリは設計どおりに動いていましたが、実際の採用視点では明らかに欠けているロジックがありました:サン・トラッカーは実際に太陽が出ているかどうかをチェックするべきです。
これは AI の失敗ではなく、私のプロダクトの考え方の問題でした。ツールは意図に従って動くので、正しい意図を持つのが仕事です。そこで GPS 座標に基づいて日の出と日の入りを計算するロジックを追加しました。夜は Sunny を睡眠状態にしました:半開きの目、"zzz" の兆候、垂れた腕、そして無効化されたボタン(表示: Sunny は眠っています。日の出にまた会いましょう)。
このひとつのバグ修正でアプリはずっと良くなり、AI を使って作ることが「実際に使ってみる必要」を取り除かないことを思い出させてくれました。
コード署名
もっとも「ネイティブはやっぱりネイティブだ」と感じた瞬間はコード署名でした。プッシュ通知の権限...