ここで紹介する内容は 「Webが先にあって、Nativeを後から足した」 プロジェクトでの話です。
もともと React Router v7(旧 Remix)で動いている Web アプリがあり、そこに React Native(Expo)のモバイルアプリを PoC(Proof of Concept)として追加 しました。既存の Web 向け API はすでに本番稼働していて、モバイルは「まず動くものを作って検証する」フェーズです。
# プロジェクトの時系列
2023~ Web (React Router v7) 本番稼働中
2025~ Mobile (React Native/Expo) PoC 開始 <-- いまここ
この「後追い」という文脈を理解していないと、「なぜ最初から共通化しなかったのか」という話になります。答えはシンプルで、モバイルが存在しなかったからです。
モバイル対応を検討するとき、最初に「WebView でラップすればいいのでは?」という選択肢が必ず上がります。実際に検討しました。結論として、WebView は採用しませんでした。
# WebView で困ること
- Push通知のハンドリングが困難
- カメラ・写真ライブラリへのアクセスが煩雑
- 生体認証(Face ID / Touch ID)が使えない
- オフライン時の挙動が不安定
- ネイティブのナビゲーション体験が出せない
- App Store / Google Play の審査で弾かれるリスク
特に今回のプロジェクトでは以下が決定打でした:
1. Push通知が必須 - サービスの性質上、ユーザーへのリアルタイム通知が重要な機能でした。WebView内からFCMや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クライアントをどこまで共通化するかが次の問題になります。
API層の共通化にはグラデーションがあります。「全部共通」「全部バラバラ」の二択ではなく、段階的に考えるのがポイントです。
最小限。TypeScriptの型定義だけを共有し、実装は完全に独立。
API呼び出し関数まで共有。hooksは各プラットフォーム最適化。
共通のエラー型、リトライロジック、認証ヘッダ付与まで共有。DI パターンが必要。
同じ TanStack Query hooks を Web/Native で共有。React Router v7 の loader を諦める判断が必要。
| 共通化しやすい | 分離した方がいい | 今回の判断 | |
|---|---|---|---|
| チーム | Web/Native 同一チーム | 別チーム・別会社 | 同一チーム |
| リリース | 同時リリース | 別サイクル | Web本番 / Mobile PoC |
| 認証 | 統一可能(token) | 根本的に違う | Cognito SigV4 vs ID Token |
| オフライン | 不要 | Native で必須 | 現時点では不要 |
| SSR | 不要 | Web で必須 | Web で必須 |
| API差分 | 完全同一 | 別エンドポイント | 同一バックエンド |
左寄りなら Lv.3〜4、右寄りなら Lv.1〜2。今回は混在しているため Lv.2 が最適解 という判断に。
| React Router v7 (Web) | React Native (Mobile) | |
|---|---|---|
| データ取得 | loader / action | TanStack Query |
| キャッシュ | フレームワーク管理 | 自前で設計 |
| 認証 | cookie / Cognito SigV4 | SecureStore + ID Token |
| レンダリング | SSR 前提 | クライアント完結 |
// 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} />;
}
// 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} />;
}
この違いがあるから、hooks以上の共通化(Lv.3〜4)は慎重になるべき。
手書きで API クライアントを共通化しようとすると、「共通 fetcher を誰がメンテするのか」問題が発生します。PoC フェーズではスピードが重要で、共通モジュールのメンテに時間を割きたくない。
OpenAPI スキーマ(Single Source of Truth)から各プラットフォーム向けのクライアントを自動生成する ことで、共通化の恩恵を得つつ、メンテコストを最小化できます。
# OpenAPI からの自動生成フロー
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/.../custom-fetch.ts # Token 直接送信
│ │ └── src/clients/.../generated/ # ← Orval 生成: fetch
│ └── backend/ # Hono BFF
│ └── src/handlers/ # ← Orval 生成: Hono handlers
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: {
output: { client: "zod", fileExtension: ".zod.ts" },
},
});
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
},
},
},
},
});
client: "swr" で SWR hooks を、Mobile は client: "fetch" で素の fetch 関数を生成。型定義と fetcher 関数は同じスキーマから導出されるので、型の不整合が起きない。
Orval の mutator 機能で、生成されたコードが呼ぶ fetch 関数を差し替えます。
// 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);
};
// 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 refreshed = await authService.refreshToken();
if (refreshed) {
headers.set("X-Id-Token", await authService.getCurrentToken());
response = await fetch(url, { ...options, headers });
}
}
if (!response.ok) throw new Error(await response.text());
return await response.json();
};
同じ GET /users/{userId} エンドポイントから:
// app/api-client/user/user.ts
export const useGetUser = (userId: string) => {
return useSWR(
getGetUserKey(userId),
() => customFetch<GetUserResponse>(`/users/${userId}`, { method: 'GET' })
);
};
// 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),
});
Orval は Zod スキーマや Mock Service Worker のハンドラも自動生成できます。APIが未完成の段階でもフロントエンド開発を進められる。
app/api-client/
├── user/
│ ├── user.ts # SWR hooks(Web)/ fetch関数(Mobile)
│ ├── user.zod.ts # Zod バリデーションスキーマ
│ └── user.msw.ts # MSW モックハンドラ(Web dev用)
今回の Web 側では Orval の出力に client: "swr" を使っていますが、正直なところ SWR は自前実装が結構増えて辛い です。同じ用途なら TanStack Query(旧 React Query)の方が楽な場面が多い。ここではその比較を正直に書きます。
| 機能 | SWR | TanStack Query |
|---|---|---|
| Mutation | useSWRMutation + 手動キャッシュ操作 | useMutation で Optimistic Update + 自動ロールバック |
| キャッシュ無効化 | 文字列キーを手動列挙。リファクタで漏れる | invalidateQueries で述語関数・部分一致 |
| Optimistic Updates | スナップショット管理とロールバックを手動実装 | onMutate / onError / onSettled のライフサイクルフック |
| Infinite Query | useSWRInfinite で終了判定・ページ結合を手動 | useInfiniteQuery で hasNextPage 自動管理 |
| Devtools | 公式なし。キャッシュ状態のデバッグが辛い | 公式 Devtools でキャッシュ可視化・手動操作 |
| リトライ制御 | onErrorRetry に全ロジック手書き | ステータスコード別リトライ、指数バックオフ標準 |
| Dependent Queries | 条件付きキー(三項演算子)で暗黙的 | enabled オプションで宣言的 |
// SWR: Optimistic Update を自前で書く
const { trigger } = useSWRMutation('/api/todos', updateTodo, {
optimisticData(current) {
// スナップショット保存 + 楽観更新を手動で
return current.map(t => t.id === id ? { ...t, done: true } : t);
},
rollbackOnError: true,
// revalidate でサーバーと同期
revalidate: true,
// ← 関連する他のキャッシュは? 手動で mutate() を呼ぶ必要あり
});
// TanStack Query: ライフサイクルフックで宣言的
const mutation = useMutation({
mutationFn: updateTodo,
async onMutate(newTodo) {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previous = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], old =>
old.map(t => t.id === newTodo.id ? { ...t, ...newTodo } : t)
);
return { previous }; // ← ロールバック用
},
onError(err, vars, context) {
queryClient.setQueryData(['todos'], context.previous); // 自動ロールバック
},
onSettled() {
queryClient.invalidateQueries({ queryKey: ['todos'] }); // 関連キャッシュも一括
},
});
// SWR: 関連キャッシュを手動で列挙
await mutate('/api/users/123');
await mutate('/api/users'); // ← 一覧も忘れずに
await mutate('/api/users/123/posts'); // ← これ忘れがち
await mutate('/api/teams/456'); // ← チーム情報にユーザー名が...
// → エンドポイントが増えるたびに追加漏れのリスク
// TanStack Query: パターンマッチで一括無効化
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'users' ||
(query.queryKey[0] === 'teams' && query.state.data?.members?.includes(userId))
});
React Router v7 の loader でデータを取得し、コンポーネントで SWR を使う場合、キャッシュが二重管理 になる問題があります。
// loader でデータを取得
export async function loader() {
const user = await getUser(userId); // サーバーサイドで取得
return { user };
}
// コンポーネントで SWR を使うと...
function UserPage() {
const { user: initialUser } = useLoaderData();
const { data: user } = useSWR('/api/users/123', fetcher, {
fallbackData: initialUser, // ← loader のデータを初期値に
});
// → loader のキャッシュと SWR のキャッシュが別物。同期が面倒。
}
// loader で prefetch → コンポーネントと同じキャッシュ
export async function loader() {
await queryClient.ensureQueryData({
queryKey: ['user', userId],
queryFn: () => getUser(userId),
});
return null; // データはキャッシュ経由で渡る
}
function UserPage() {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => getUser(userId),
});
// → loader と同じキャッシュ。初回はサーバー取得、以降はクライアントキャッシュ。
}
ここまで書くと「じゃあ TanStack Query にすればいいのでは」と思われるかもしれません。実際その通りなのですが、今回は 既存の Web アプリが SWR で実装済み だったため、PoC フェーズで移行コストをかける判断をしていません。
Mobile 側は新規なので TanStack Query を採用しています。Web 側は SWR のまま運用を継続する方針です。
「ライブラリの比較表」は世の中にたくさんあります。ここでは 同じプロジェクト内で両方を使っている実装 から、具体的にどう変わるのかを見ていきます。
ユーザー情報を取得する GET /me エンドポイント。AWS SigV4 署名 + ID Token が必要なケースです。
export const useGetMe = () => {
const { isAuthenticated } = useAuthContext();
const url = isAuthenticated
? `${resourceApiBaseUrl}/me`
: null; // ← null キーで fetch を抑制
return useSWR<GetMeResponse>(
url,
async (fetchUrl: string) => {
const idToken = await getOrRefreshIdToken();
const signedHeaders = await getSignedHeaders({
url: fetchUrl, method: "GET",
requestBody: "", idToken,
});
const response = await fetch(fetchUrl, {
method: "GET", headers: signedHeaders,
});
if (!response.ok) throw new Error(`${response.status}`);
return await response.json();
},
);
};
export const useGetMe = () => {
const { isAuthenticated } = useAuthContext();
return useQuery({
queryKey: ['me'],
queryFn: async () => {
const idToken = await getOrRefreshIdToken();
const signedHeaders = await getSignedHeaders({
url: `${resourceApiBaseUrl}/me`, method: "GET",
requestBody: "", idToken,
});
const response = await fetch(`${resourceApiBaseUrl}/me`, {
method: "GET", headers: signedHeaders,
});
if (!response.ok) throw new Error(`${response.status}`);
return await response.json();
},
enabled: isAuthenticated, // ← 宣言的に fetch を制御
});
};
Read だけなら大差はない。 SWR は null キー、TanStack Query は enabled オプションで条件付きフェッチを制御します。fetcher 関数の中身は同じで、差は API surface だけ。
会員コンテンツの「視聴済み」を記録するケース。SWR ではフック外に関数を書くしかありませんが、TanStack Query は useMutation でキャッシュ更新まで宣言的に扱えます。
// hooks とは別に、素の async 関数として定義
export const recordViewActivity = async (
serviceId: string, contentId: string
): Promise<boolean> => {
const idToken = await getOrRefreshIdToken();
if (!idToken) return false;
const url = `${baseUrl}/services/${serviceId}/membership-contents/${contentId}/activities`;
const body = JSON.stringify({ type: "view" });
const signedHeaders = await getSignedHeaders({ url, method: "POST", requestBody: body, idToken });
const response = await fetch(url, {
method: "POST",
headers: { ...signedHeaders, "Content-Type": "application/json" },
body,
});
return response.ok;
};
// コンポーネント側: SWR のキャッシュ更新は手動
const { mutate } = useMembershipActivities(serviceId);
const handleView = async () => {
await recordViewActivity(serviceId, contentId);
mutate(); // ← 手動で再検証。関連キャッシュは?
};
export const useRecordViewActivity = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ serviceId, contentId }: Params) => {
const idToken = await getOrRefreshIdToken();
const url = `${baseUrl}/services/${serviceId}/membership-contents/${contentId}/activities`;
const body = JSON.stringify({ type: "view" });
const signedHeaders = await getSignedHeaders({ url, method: "POST", requestBody: body, idToken });
const response = await fetch(url, {
method: "POST",
headers: { ...signedHeaders, "Content-Type": "application/json" },
body,
});
if (!response.ok) throw new Error(`${response.status}`);
},
onSuccess(_, { serviceId }) {
// 関連キャッシュを一括無効化
queryClient.invalidateQueries({
queryKey: ['membership-activities', serviceId],
});
queryClient.invalidateQueries({
queryKey: ['membership-contents', serviceId],
});
},
});
};
// コンポーネント側: mutation の状態もフックで管理
const { mutate, isPending } = useRecordViewActivity();
const handleView = () => mutate({ serviceId, contentId });
mutate() で手動キャッシュ更新。TanStack Query は useMutation で mutation もフック内に閉じ込められるため、ローディング状態(isPending)やエラー状態の管理が宣言的。関連キャッシュの無効化も onSuccess で一箇所にまとまる。
レッスンの作成・更新・削除を含む CRUD 操作で、実装量の差が顕著になります。
// 一覧取得
export const useLessons = () =>
useQuery({ queryKey: ['lessons'], queryFn: () => getLessons() });
// 作成
export const useCreateLesson = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: createLesson,
onSuccess: () => qc.invalidateQueries({ queryKey: ['lessons'] }),
});
};
// 更新(詳細キャッシュも無効化)
export const useUpdateLesson = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Lesson> }) =>
updateLesson(id, data),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: ['lessons'] });
qc.invalidateQueries({ queryKey: ['lesson', id] });
},
});
};
// 削除
export const useDeleteLesson = () => {
const qc = useQueryClient();
return useMutation({
mutationFn: deleteLesson,
onSuccess: () => qc.invalidateQueries({ queryKey: ['lessons'] }),
});
};
SWR で同じ CRUD を書くと、create / update / delete はすべて素の async 関数 + 手動 mutate() になります。hooks としてまとめようとすると useSWRMutation を使うことになりますが、一覧キャッシュと詳細キャッシュの両方を無効化するようなケースでは、キー文字列を手動列挙する必要がある。
実際のプロジェクトでは SWR と TanStack Query を 同時に使っています。Provider はネストするだけ。
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false },
},
});
export const AppProvider = ({ children }) => (
<QueryClientProvider client={queryClient}>
<SWRConfig value={globalSWROptions}>
<AuthProvider>
{children}
</AuthProvider>
</SWRConfig>
</QueryClientProvider>
);
2 つのキャッシュストアが独立して動く。これはデメリットにも見えますが、移行期においては「壊さずに段階移行できる」という大きなメリットがあります。
| 用途 | SWR が向いている | TanStack Query が向いている |
|---|---|---|
| Read のみ | o 十分 | o 十分 |
| Mutation あり | 手動キャッシュ操作 | useMutation で宣言的 |
| Optimistic Update | スナップショット管理を自前 | ライフサイクルフック標準 |
| キャッシュ無効化 | キー文字列を列挙 | 述語関数で一括 |
| バンドルサイズ | ~5KB gzipped | ~16KB gzipped |
| 既存 Orval 連携 | query のみ自動生成 | query + mutation 自動生成 |
| 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 |
React Tokyo Fes 2026 ポスターセッション「React Router x React Native APIクライアント どこまで共通化する?」の補助資料。