Web/Native API層共通化戦略 - React Router v7 と React Native で最適な共通化レベルを選ぶ
既存のReact Router v7 WebアプリにReact Nativeを後追いで導入した実プロジェクトの経験から、API層をどこまで共通化すべきかを4段階のレベルで整理する。OpenAPI + Orvalによる薄い共通化の実践パターンも紹介。
Web/Native API層共通化戦略
React Router v7 と React Native で最適な共通化レベルを選ぶ実践ガイド。
この記事は React Tokyo Fes 2025 でのポスターセッション「React Router x React Native APIクライアント どこまで共通化する?」の補助資料です。
前提: このプロジェクトの状況
最初に断っておくと、ここで紹介する内容は 「Web が先にあって、Native を後から足した」 プロジェクトでの話です。
もともと React Router v7(旧 Remix)で動いている Web アプリがあり、そこに React Native(Expo)のモバイルアプリを PoC(Proof of Concept)として追加 しました。既存の Web 向け API はすでに本番稼働していて、モバイルは「まず動くものを作って検証する」フェーズです。
つまり、ゼロから Web / Native を同時に設計したわけではない。既存の Web API 資産をどこまでモバイルに流用できるか が論点のスタート地点でした。
時系列:
2023~ Web (React Router v7) 本番稼働中
2025~ Mobile (React Native/Expo) PoC 開始 ← いまここ
この「後追い」という文脈を理解していないと、「なぜ最初から共通化しなかったのか」という話になってしまう。答えはシンプルで、モバイルが存在しなかったから です。
なぜ WebView ではダメだったのか
モバイル対応を検討するとき、最初に「WebView でラップすればいいのでは?」という選択肢が必ず上がります。実際に検討しました。結論として、WebView は採用しませんでした。
WebView の限界
WebView で困ること:
- Push通知のハンドリングが困難
- カメラ・写真ライブラリへのアクセスが煩雑
- 生体認証(Face ID / Touch ID)が使えない
- オフライン時の挙動が不安定
- ネイティブのナビゲーション体験が出せない
- App Store / Google Play の審査で弾かれるリスク
特に今回のプロジェクトでは以下が決定打でした:
1. Push通知が必須 サービスの性質上、ユーザーへのリアルタイム通知が重要な機能でした。WebView 内から Firebase Cloud Messaging や APNs を扱うのは、ネイティブブリッジを自前で書くことになり、WebView のメリット(Web資産の流用)が相殺されます。
2. 決済フロー アプリ内課金(IAP)を将来的に組み込む可能性がありました。WebView 経由の決済は Apple のガイドライン上グレーゾーンで、審査リスクが高い。
3. パフォーマンス 既存 Web は SSR 前提の設計で、React Router v7 の loader / action パターンに強く依存しています。これを WebView で動かすと、サーバーとの往復が増えてモバイル回線でのレイテンシが目立つ。
4. UX の期待値 PoC とはいえ、社内ステークホルダーに見せるものである以上、「Web をそのまま表示している」と見抜かれるレベルの体験では評価されない。ネイティブのジェスチャー、トランジション、スクロール挙動は WebView では再現できません。
判断フロー:
WebView で十分?
├── Push通知不要 & 課金なし & 情報閲覧のみ → WebView でOK
└── それ以外 → React Native を検討
結果として「Web の API 資産は流用するが、UI 層は Native で書く」という方針に落ち着きました。そうなると、API クライアントをどこまで共通化するか が次の問題になります。
共通化の 4 レベル
API 層の共通化にはグラデーションがあります。「全部共通」「全部バラバラ」の二択ではなく、段階的に考えるのがポイントです。
Lv.1 - 型定義のみ共通
typescript// packages/shared-types/user.ts // Web でも Mobile でもこの型を import する export interface User { id: string; name: string; email: string; avatar_url: string | null; } export interface GetUserResponse { user: User; }
共有するのはインタフェース定義だけ。API の呼び出し方、エラーハンドリング、キャッシュ戦略は完全に別。最小限のアプローチ。
Lv.2 - 型 + Fetcher 関数を共通(推奨)
typescript// packages/api-client/user.ts // 型と fetch 関数を共有。hooks はプラットフォーム別。 export const getUser = async (userId: string): Promise<GetUserResponse> => { const response = await fetch(`/api/users/${userId}`); if (!response.ok) throw new ApiError(response); return response.json(); };
Web 側は SWR / loader から呼ぶ。Mobile 側は TanStack Query から呼ぶ。呼び出し関数は同じだが、データ取得の仕組みはプラットフォームに最適化する。
Lv.3 - Fetcher + エラーハンドリング共通
typescript// 共通エラー型、リトライロジック、認証ヘッダ付与まで共有 export const apiClient = { get: async <T>(path: string): Promise<T> => { const token = await getToken(); // プラットフォーム別に実装 const res = await fetch(path, { headers: { Authorization: `Bearer ${token}` }, }); if (res.status === 401) { await refreshToken(); return apiClient.get<T>(path); // 1回リトライ } if (!res.ok) throw new ApiError(res); return res.json(); }, };
ここまで来ると「認証トークンの取得」がプラットフォーム依存になるため、DI(依存注入)パターンが必要になる。
Lv.4 - Hooks・キャッシュ戦略まで共通
typescript// 同じ TanStack Query hooks を Web/Native の両方で使う export const useUser = (userId: string) => { return useQuery({ queryKey: ['user', userId], queryFn: () => getUser(userId), staleTime: 5 * 60 * 1000, }); };
Web も Mobile も TanStack Query に統一する場合のパターン。React Router v7 の loader/action を諦める判断が必要。
プロジェクト特性から判断する
「どのレベルにするか」はプロジェクトの状況で変わります。
共通化しやすい 分離した方がいい
チーム Web/Native 同一チーム 別チーム・別会社
リリース 同時リリース 別サイクル
認証方式 統一可能(token) 根本的に違う
オフライン 不要 Native で必須
SSR 不要 Web で必須
API差分 完全同一 プラットフォーム別
左寄りなら Lv.3〜4、右寄りなら Lv.1〜2。
今回のプロジェクトは:
- チーム: 同一チーム(左寄り)
- リリース: Web は本番、Mobile は PoC(右寄り)
- 認証: Web は Cognito + AWS SigV4 署名、Mobile は ID Token 直接送信(右寄り)
- SSR: Web で必須(右寄り)
- API: 同一のバックエンド API を使用(左寄り)
→ 結果として Lv.2 が最適解 という判断になりました。
共通化できるもの / 分離すべきもの
共通化しやすい
- 型定義(リクエスト / レスポンス)
- エンドポイント定義(パス、HTTPメソッド)
- バリデーションスキーマ(Zod)
- 基本的な fetcher 関数(URL 組み立て + fetch 呼び出し)
分離を検討すべき
- HTTP クライアント設定(認証ヘッダの付け方が違う)
- 認証トークンの取得・保存(cookie vs SecureStore)
- エラー時の UI 処理(Toast vs Alert)
- キャッシュ戦略(loader vs TanStack Query)
React Router v7 と React Native、何が違う?
同じ React でも、データ取得のアプローチが根本的に異なります。
React Router v7(Web)
typescript// loader でサーバーサイドデータ取得 export async function loader({ params }: LoaderFunctionArgs) { const user = await getUser(params.userId); return { user }; } export default function UserPage() { const { user } = useLoaderData<typeof loader>(); return <UserProfile user={user} />; }
loader/actionでデータ取得(フレームワーク管理)- キャッシュはフレームワークが管理
- cookie ベースの認証が自然
- SSR を前提とした設計
React Native(Expo)
typescript// TanStack Query でクライアントサイドデータ取得 export default function UserScreen() { const { data: user, isLoading } = useQuery({ queryKey: ['user', userId], queryFn: () => getUser(userId), }); if (isLoading) return <LoadingSpinner />; return <UserProfile user={user} />; }
- TanStack Query でデータ取得(自前管理)
- キャッシュ戦略を自分で設計する必要がある
- SecureStore + token ベースの認証
- クライアント完結
この違いがあるから、hooks 以上の共通化(Lv.3〜4)は慎重になるべき。
実践: OpenAPI + Orval による薄い共通化
ここからは実際のプロジェクトで採用している手法を紹介します。
なぜ OpenAPI + Orval なのか
手書きで API クライアントを共通化しようとすると、「共通 fetcher を誰がメンテするのか」問題が発生します。PoC フェーズではなおさらスピードが重要で、共通モジュールのメンテに時間を割きたくない。
OpenAPI スキーマ(Single Source of Truth)から各プラットフォーム向けのクライアントを自動生成する ことで、共通化の恩恵を得つつ、メンテコストを最小化できます。
OpenAPI 定義(1つ)
│
├── orval → Web 用クライアント(SWR hooks + customFetch)
├── orval → Mobile 用クライアント(fetch + customFetch)
└── orval → Backend 用クライアント(S2S fetch)
Orval は OpenAPI スキーマから TypeScript の API クライアントコードを自動生成するツールです。出力形式を swr、fetch、zod などから選べるのが特徴で、同じスキーマから用途別のクライアントを生成 できます。
モノレポ構成
taiyaki/
├── openapi/
│ └── openapi.yml # BFF の API 定義
├── packages/
│ ├── frontend/ # React Router v7 (Web)
│ │ ├── orval.config.ts
│ │ ├── app/custom-fetch.ts # Cognito + AWS SigV4 署名
│ │ └── app/api-client/ # ← Orval 生成: SWR hooks
│ ├── mobile/ # React Native (Expo)
│ │ ├── orval.config.ts
│ │ ├── src/clients/api-client/custom-fetch.ts # Token 直接送信
│ │ └── src/clients/api-client/generated/ # ← Orval 生成: fetch
│ └── backend/ # Hono BFF
│ ├── orval.config.ts
│ └── src/handlers/ # ← Orval 生成: Hono handlers
Web 用 Orval 設定
typescript// packages/frontend/orval.config.ts import { defineConfig } from "orval"; export default defineConfig({ appClient: { input: { target: "../../core-api/openapi/_generated.yaml", }, output: { mode: "tags-split", target: "./app/api-client", client: "swr", // ← SWR hooks を生成 httpClient: "fetch", mock: { type: "msw", delay: 0 }, // ← MSW モックも自動生成 override: { mutator: { path: "app/custom-fetch.ts", name: "customFetch", // ← Cognito 署名付き fetch }, }, }, }, // Zod バリデーションも同時生成 appClientZod: { input: { target: "../../core-api/openapi/_generated.yaml", }, output: { client: "zod", target: "./app/api-client", fileExtension: ".zod.ts", }, }, });
Mobile 用 Orval 設定
typescript// packages/mobile/orval.config.ts import { defineConfig } from "orval"; export default defineConfig({ appClient: { input: { target: "../../core-api/openapi/_generated.yaml", }, output: { mode: "tags-split", target: "./src/clients/api-client/generated", client: "fetch", // ← 素の fetch 関数を生成(hooks なし) httpClient: "fetch", override: { mutator: { path: "src/clients/api-client/custom-fetch.ts", name: "customFetch", // ← Token 直接送信の fetch }, }, }, }, });
同じ OpenAPI スキーマ から、Web は client: "swr" で SWR hooks を、Mobile は client: "fetch" で素の fetch 関数を生成しています。型定義と fetcher 関数は同じスキーマから導出されるので、型の不整合が起きない。
customFetch: プラットフォーム別の認証
共通化の「分離すべきもの」として挙げた認証部分。Orval の mutator 機能で、生成されたコードが呼ぶ fetch 関数を差し替えます。
Web(Cognito + AWS SigV4):
typescript// packages/frontend/app/custom-fetch.ts export const customFetch = async <T>( url: string, options: CustomFetchOptions, ): Promise<T> => { // AWS SigV4 で署名 const signedHeaders = await getSignedHeaders(url, options.method, options.body); // Cognito JWT を追加 const cognitoJwt = await getCognitoUserPoolJwtToken(); if (cognitoJwt) { signedHeaders["X-Cognito-Jwt"] = cognitoJwt; } const response = await fetch(url, { ...options, headers: { ...options.headers, ...signedHeaders }, }); if (!response.ok) { throw { message: response.statusText, status: response.status }; } return getBody<T>(response); };
Mobile(ID Token 直接送信 + リトライ):
typescript// packages/mobile/src/clients/api-client/custom-fetch.ts export const customFetch = async <T>( url: string, options: RequestInit = {}, ): Promise<T> => { const headers = new Headers(options.headers); headers.set("Content-Type", "application/json"); // 認証サービスから ID Token を取得 const idToken = await authService.getCurrentToken(); if (idToken) { headers.set("X-Id-Token", idToken); } let response = await fetch(url, { ...options, headers }); // 401 → トークンリフレッシュ → リトライ(1回のみ) if (response.status === 401) { const refreshedToken = await authService.refreshToken(); if (refreshedToken) { const newIdToken = await authService.getCurrentToken(); if (newIdToken) headers.set("X-Id-Token", newIdToken); response = await fetch(url, { ...options, headers }); } } if (!response.ok) throw new Error(await response.text()); return await response.json(); };
同じ Orval 生成コードが、プラットフォーム別の customFetch を通って認証付きリクエストになる。 これが Lv.2 共通化の実体です。
生成されるコードの違い
同じ GET /users/{userId} エンドポイントから:
Web(SWR hooks):
typescript// 自動生成: app/api-client/user/user.ts export const useGetUser = (userId: string) => { return useSWR( getGetUserKey(userId), () => customFetch<GetUserResponse>(`/users/${userId}`, { method: 'GET' }) ); };
Mobile(fetch 関数):
typescript// 自動生成: src/clients/api-client/generated/user/user.ts export const getUser = (userId: string) => { return customFetch<GetUserResponse>(`/users/${userId}`, { method: 'GET' }); }; // Mobile 側で自前の hooks を書く const { data } = useQuery({ queryKey: ['user', userId], queryFn: () => getUser(userId), });
Zod バリデーションの共有
Orval は Zod スキーマも自動生成できます。Web / Mobile の両方で同じバリデーションルールを使えるのは大きなメリットです。
typescript// 自動生成: app/api-client/user/user.zod.ts export const getUserResponseZod = z.object({ user: z.object({ id: z.string().uuid(), name: z.string().min(1), email: z.string().email(), avatar_url: z.string().nullable(), }), });
MSW モックの自動生成(Web 開発向け)
Web の Orval 設定に mock: { type: "msw" } を追加すると、Mock Service Worker のハンドラも自動生成されます。API が未完成の段階でもフロントエンド開発を進められる。
typescript// 自動生成: app/api-client/user/user.msw.ts export const getGetUserMock = () => http.get('/users/:userId', () => { return HttpResponse.json({ user: { id: 'uuid', name: 'John', email: 'john@example.com', avatar_url: null } }); });
Mobile の PoC でも、先に Web 用の MSW モックで API 仕様を固めてから Mobile 実装に入る、という流れが取れます。
まとめ: 後追い Native で Lv.2 が最適解な理由
Lv.1 Lv.2 Lv.3 Lv.4
型の共有 o o o o
fetcher 共有 x o o o
エラーHD 共有 x x o o
hooks 共有 x x x o
------------------------------------------------------
導入コスト 低 低〜中 中 高
メンテコスト 低 低 中 高
RR v7 loader 活用 o o o x
PoC スピード o o △ x
- Lv.1 だと: 型がズレるリスクがある。手動で合わせる手間。
- Lv.2 だと: OpenAPI + Orval で自動生成すれば型もfetcherも揃う。hooks はプラットフォーム最適化できる。
- Lv.3 だと: 認証の抽象化が必要。PoC にはオーバーエンジニアリング。
- Lv.4 だと: React Router v7 の loader を捨てることになる。既存 Web への影響が大きい。
「既存 Web を壊さず、Mobile を速く立ち上げる」 なら、Lv.2 + OpenAPI/Orval が現時点での最適解。
PoC で検証が進み、Mobile が本格運用フェーズに入ったら Lv.3 への段階的な移行を検討すればいい。最初から完璧な共通化を目指すより、今の状況に合ったレベルを選んで、必要に応じてレベルを上げていく のが現実的なアプローチです。
React Tokyo Fes 2025 ポスターセッション補助資料