React Router で Orval + SWR を使用している環境から、React Native への移行時に必要な対応と、Orval + React Query との実装差分をまとめる。
→TanStack Queryは?というあたりについてはそこまでまにあわなかったごめんなのでべつでかく。
目次
- 概要:Orval が生成するコードの違い
- SWR を React Native で使う際の必須設定
- キャッシュの永続化(オフライン対応)
- SWR vs React Query:機能比較
- Orval 設定の違い
- 実装パターン別コード例
- 移行時のチェックリスト
1. 概要
Orval とは
Orval は OpenAPI (Swagger) 仕様から型安全な API クライアントを自動生成するツール。
サポートするクライアント
- swr - SWR フック生成
- react-query - TanStack Query フック生成
- axios / axios-functions - Axios ベースのクライアント
- fetch - Fetch API ベース
- その他(vue-query, svelte-query, angular 等)
Web と React Native での根本的な違い
| 項目 | Web (React Router) | React Native |
|---|---|---|
| フォーカス検知 | window.focus イベント |
AppState API |
| ネットワーク状態 | navigator.onLine |
@react-native-community/netinfo |
| キャッシュ永続化 | localStorage |
AsyncStorage / MMKV |
| バックグラウンド | タブ切り替え | アプリがバックグラウンドへ |
2. SWR を React Native で使う際の必須設定
2.1 問題点
SWR の revalidateOnFocus と revalidateOnReconnect は Web 専用 。React Native では以下が動作しない
// ❌ これらのオプションは React Native でデフォルトでは動作しない useSWR('/api/user', fetcher, { revalidateOnFocus: true, // window.focus を使用 revalidateOnReconnect: true // navigator.onLine を使用 })
2.2 解決策 A:公式ドキュメントの方法(手動設定)
// providers/SWRProvider.tsx import { AppState, AppStateStatus } from 'react-native'; import NetInfo from '@react-native-community/netinfo'; import { SWRConfig } from 'swr'; export function SWRProvider({ children }: { children: React.ReactNode }) { return ( <SWRConfig value={{ provider: () => new Map(), // ネットワーク状態の検知 isOnline() { return true; // NetInfo で実際の状態を返すことも可能 }, // アプリがアクティブかどうか isVisible() { return true; }, // フォーカスイベントの初期化 initFocus(callback) { let appState = AppState.currentState; const onAppStateChange = (nextAppState: AppStateStatus) => { // バックグラウンドからアクティブに復帰した時 if ( appState.match(/inactive|background/) && nextAppState === 'active' ) { callback(); } appState = nextAppState; }; const subscription = AppState.addEventListener( 'change', onAppStateChange ); return () => { subscription.remove(); }; }, // ネットワーク復帰イベントの初期化 initReconnect(callback) { const unsubscribe = NetInfo.addEventListener((state) => { if (state.isConnected) { callback(); } }); return () => { unsubscribe(); }; }, }} > {children} </SWRConfig> ); }
2.3 解決策 B:@nandorojo/swr-react-native(推奨)
より簡単な方法として、専用ライブラリを使用
# インストール yarn add @nandorojo/swr-react-native yarn add @react-native-community/netinfo
// 方法1: useSWR を置き換え import useSWRNative from '@nandorojo/swr-react-native'; function UserProfile() { // useSWR と同じ API、React Native 対応済み const { data, error, mutate } = useSWRNative('/api/user', fetcher); return <Text>{data?.name}</Text>; } // 方法2: 既存の useSWR に追加(useSWRInfinite 使用時に便利) import useSWR from 'swr'; import { useSWRNativeRevalidate } from '@nandorojo/swr-react-native'; function UserProfile() { const { data, mutate } = useSWR('/api/user', fetcher); // 再検証ロジックを追加 useSWRNativeRevalidate({ mutate }); return <Text>{data?.name}</Text>; }
3. キャッシュの永続化
3.1 SWR のキャッシュ問題
重要: SWR はデフォルトで インメモリキャッシュ を使用。アプリを終了するとキャッシュは消える。
// ❌ アプリ再起動でキャッシュが消える <SWRConfig value={{ provider: () => new Map() }}> {children} </SWRConfig>
3.2 SWR + AsyncStorage での永続化
// lib/swr-cache-provider.ts import AsyncStorage from '@react-native-async-storage/async-storage'; const CACHE_KEY = 'swr-cache'; export function createAsyncStorageCacheProvider() { const map = new Map<string, any>(); // 起動時にキャッシュを復元 async function initCache() { try { const stored = await AsyncStorage.getItem(CACHE_KEY); if (stored) { const parsed = JSON.parse(stored); Object.entries(parsed).forEach(([key, value]) => { map.set(key, value); }); } } catch (e) { console.error('Failed to restore cache:', e); } } // キャッシュを保存 async function persistCache() { try { const obj: Record<string, any> = {}; map.forEach((value, key) => { obj[key] = value; }); await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(obj)); } catch (e) { console.error('Failed to persist cache:', e); } } return { initCache, provider: () => { return { get: (key: string) => map.get(key), set: (key: string, value: any) => { map.set(key, value); // 変更時に保存(スロットリング推奨) persistCache(); }, delete: (key: string) => { map.delete(key); persistCache(); }, keys: () => map.keys(), }; }, }; }
3.3 swr-sync-native-storage パッケージ
yarn add swr-sync-storage @react-native-async-storage/async-storage
import { syncWithStorage } from 'swr-sync-storage'; import AsyncStorage from '@react-native-async-storage/async-storage'; // アプリ起動時に一度だけ呼び出し const { success, unsubscribe } = await syncWithStorage({ storage: AsyncStorage, // オプション excludeKeys: [/^private/], // 特定のキーを除外 });
3.4 React Query のキャッシュ永続化(比較)
React Query は公式プラグインで永続化をサポート:
yarn add @tanstack/query-async-storage-persister yarn add @tanstack/react-query-persist-client
// providers/QueryProvider.tsx import { QueryClient } from '@tanstack/react-query'; import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'; import AsyncStorage from '@react-native-async-storage/async-storage'; const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 1000 * 60 * 60 * 24, // 24時間 staleTime: 1000 * 60 * 5, // 5分 }, }, }); const asyncStoragePersister = createAsyncStoragePersister({ storage: AsyncStorage, key: 'REACT_QUERY_CACHE', }); export function QueryProvider({ children }: { children: React.ReactNode }) { return ( <PersistQueryClientProvider client={queryClient} persistOptions={{ persister: asyncStoragePersister, maxAge: 1000 * 60 * 60 * 24, // 24時間 }} > {children} </PersistQueryClientProvider> ); }
3.5 MMKV を使用した高速キャッシュ(推奨)
MMKV は AsyncStorage より 約30倍高速 とのこと。 こちらはちゃんと試してない。あとで試す。
yarn add react-native-mmkv
// lib/mmkv-storage.ts import { MMKV } from 'react-native-mmkv'; export const storage = new MMKV({ id: 'query-cache', }); // React Query 用アダプター export const mmkvStorageAdapter = { getItem: (key: string) => { const value = storage.getString(key); return value ?? null; }, setItem: (key: string, value: string) => { storage.set(key, value); }, removeItem: (key: string) => { storage.delete(key); }, }; // SWR 用 Provider export function createMMKVCacheProvider() { return { get: (key: string) => { const value = storage.getString(key); return value ? JSON.parse(value) : undefined; }, set: (key: string, value: any) => { storage.set(key, JSON.stringify(value)); }, delete: (key: string) => { storage.delete(key); }, keys: function* () { const allKeys = storage.getAllKeys(); for (const key of allKeys) { yield key; } }, }; }
4. SWR vs React Query:機能比較
4.1 機能対照表
| 機能 | SWR | React Query | 備考 |
|---|---|---|---|
| バンドルサイズ | ~5.3KB | ~16.2KB | SWR は約1/3 |
| DevTools | ❌ なし | ✅ 公式提供 | デバッグに大きな差 |
| キャッシュ永続化 | 自前実装必要 | 公式プラグイン | React Query が楽 |
| Optimistic Updates | △ 基本的 | ✅ 充実 | rollback 等 |
| ページネーション | useSWRInfinite |
useInfiniteQuery |
同等 |
| Mutation | useSWRMutation |
useMutation |
React Query が豊富 |
| クエリ無効化 | mutate(key) |
invalidateQueries |
React Query が柔軟 |
| リトライ | ✅ | ✅ | 同等 |
| 並列フェッチ | ✅ | ✅ | 同等 |
| React Native 対応 | 追加設定必要 | ほぼそのまま | React Query が楽 |
4.2 Orval 生成コードの違い
SWR の場合
// 生成されるフック (Orval + SWR) export const useGetUser = ( userId: string, options?: SWRConfiguration<User, Error> ) => { return useSWR<User, Error>( userId ? `/api/users/${userId}` : null, () => customFetch<User>({ url: `/api/users/${userId}` }), options ); }; // Mutation は useSWRMutation export const useCreateUser = ( options?: SWRMutationConfiguration<User, Error, string, CreateUserBody> ) => { return useSWRMutation<User, Error, string, CreateUserBody>( '/api/users', (_url, { arg }) => customFetch<User>({ url: '/api/users', method: 'POST', data: arg, }), options ); };
React Query の場合
// 生成されるフック (Orval + React Query) export const useGetUser = ( userId: string, options?: UseQueryOptions<User, Error> ) => { return useQuery<User, Error>({ queryKey: ['users', userId], queryFn: () => customFetch<User>({ url: `/api/users/${userId}` }), enabled: !!userId, ...options, }); }; // Mutation export const useCreateUser = ( options?: UseMutationOptions<User, Error, CreateUserBody> ) => { return useMutation<User, Error, CreateUserBody>({ mutationFn: (data) => customFetch<User>({ url: '/api/users', method: 'POST', data, }), ...options, }); };
5. Orval 設定の違い
5.1 SWR 用設定
// orval.config.ts
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
input: './openapi.yaml',
output: {
target: './src/api/generated.ts',
client: 'swr',
// HTTP クライアント(fetch or axios)
httpClient: 'fetch',
// SWR 固有オプション
override: {
swr: {
// useSWRInfinite を生成
useInfinite: true,
// useSWRMutation を生成(GET 以外のリクエスト用)
useMutation: true,
// デフォルトオプション
options: {
revalidateOnFocus: false,
revalidateOnReconnect: false,
},
},
// カスタムフェッチャー
mutator: {
path: './src/lib/custom-fetch.ts',
name: 'customFetch',
},
},
},
},
});
5.2 React Query 用設定
// orval.config.ts
import { defineConfig } from 'orval';
export default defineConfig({
petstore: {
input: './openapi.yaml',
output: {
target: './src/api/generated.ts',
client: 'react-query',
httpClient: 'fetch',
override: {
query: {
// useInfiniteQuery を生成
useInfinite: true,
// useSuspenseQuery を生成
useSuspense: false,
// React Query v5 対応
version: 5,
// デフォルトオプション
options: {
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 60,
},
},
mutator: {
path: './src/lib/custom-fetch.ts',
name: 'customFetch',
},
},
},
},
});
5.3 カスタムフェッチャー(共通)
// src/lib/custom-fetch.ts
import { Platform } from 'react-native';
const BASE_URL = __DEV__
? Platform.select({
ios: 'http://localhost:3000',
android: 'http://10.0.2.2:3000', // Android エミュレータ
default: 'http://localhost:3000',
})
: 'https://api.example.com';
interface RequestConfig {
url: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
data?: unknown;
params?: Record<string, string>;
headers?: Record<string, string>;
signal?: AbortSignal;
}
export async function customFetch<T>({
url,
method = 'GET',
data,
params,
headers,
signal,
}: RequestConfig): Promise<T> {
const queryString = params
? '?' + new URLSearchParams(params).toString()
: '';
const response = await fetch(`${BASE_URL}${url}${queryString}`, {
method,
headers: {
'Content-Type': 'application/json',
...headers,
},
body: data ? JSON.stringify(data) : undefined,
signal,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
6. 実装パターン別コード例
6.1 基本的なデータ取得
SWR
// components/UserProfile.tsx import { useGetUser } from '@/api/generated'; import { View, Text, ActivityIndicator } from 'react-native'; export function UserProfile({ userId }: { userId: string }) { const { data, error, isLoading, mutate } = useGetUser(userId); if (isLoading) return <ActivityIndicator />; if (error) return <Text>Error: {error.message}</Text>; return ( <View> <Text>{data?.name}</Text> <Button onPress={() => mutate()} title="Refresh" /> </View> ); }
React Query
// components/UserProfile.tsx import { useGetUser } from '@/api/generated'; import { View, Text, ActivityIndicator } from 'react-native'; export function UserProfile({ userId }: { userId: string }) { const { data, error, isLoading, refetch } = useGetUser(userId); if (isLoading) return <ActivityIndicator />; if (error) return <Text>Error: {error.message}</Text>; return ( <View> <Text>{data?.name}</Text> <Button onPress={() => refetch()} title="Refresh" /> </View> ); }
6.2 Mutation(データ作成・更新)
SWR
import { useCreateUser, useGetUsers } from '@/api/generated'; import useSWR, { useSWRConfig } from 'swr'; function CreateUserForm() { const { trigger, isMutating, error } = useCreateUser(); const { mutate } = useSWRConfig(); const handleSubmit = async (formData: CreateUserBody) => { try { await trigger(formData); // 手動でキャッシュを無効化 mutate('/api/users'); } catch (e) { console.error(e); } }; return ( <View> {/* フォーム */} <Button onPress={() => handleSubmit({ name: 'John' })} disabled={isMutating} title={isMutating ? 'Creating...' : 'Create User'} /> </View> ); }
React Query
import { useCreateUser, useGetUsers, getGetUsersQueryKey } from '@/api/generated'; import { useQueryClient } from '@tanstack/react-query'; function CreateUserForm() { const queryClient = useQueryClient(); const { mutate, isPending, error } = useCreateUser({ onSuccess: () => { // クエリキーで無効化(型安全) queryClient.invalidateQueries({ queryKey: getGetUsersQueryKey() }); }, }); const handleSubmit = (formData: CreateUserBody) => { mutate(formData); }; return ( <View> {/* フォーム */} <Button onPress={() => handleSubmit({ name: 'John' })} disabled={isPending} title={isPending ? 'Creating...' : 'Create User'} /> </View> ); }
6.3 Optimistic Updates(楽観的更新)
SWR
import { useUpdateUser, useGetUser } from '@/api/generated'; import useSWR, { useSWRConfig } from 'swr'; function EditUserForm({ userId }: { userId: string }) { const { data: user, mutate: mutateUser } = useGetUser(userId); const { trigger } = useUpdateUser(); const handleUpdate = async (newName: string) => { // 楽観的更新 const optimisticData = { ...user!, name: newName }; try { await mutateUser( async () => { // 実際の API 呼び出し return await trigger({ userId, data: { name: newName } }); }, { optimisticData, rollbackOnError: true, revalidate: false, } ); } catch (e) { // エラー時は自動的にロールバック Alert.alert('Error', 'Failed to update'); } }; return (/* ... */); }
React Query(より充実した機能)
import { useUpdateUser, useGetUser, getGetUserQueryKey } from '@/api/generated'; import { useQueryClient } from '@tanstack/react-query'; function EditUserForm({ userId }: { userId: string }) { const queryClient = useQueryClient(); const { data: user } = useGetUser(userId); const { mutate } = useUpdateUser({ // Mutation 開始前 onMutate: async ({ userId, data }) => { // 進行中のクエリをキャンセル await queryClient.cancelQueries({ queryKey: getGetUserQueryKey(userId) }); // 前の値を保存 const previousUser = queryClient.getQueryData(getGetUserQueryKey(userId)); // 楽観的更新 queryClient.setQueryData(getGetUserQueryKey(userId), (old: User) => ({ ...old, ...data, })); // コンテキストにロールバック用データを保存 return { previousUser }; }, // エラー時のロールバック onError: (err, variables, context) => { if (context?.previousUser) { queryClient.setQueryData( getGetUserQueryKey(variables.userId), context.previousUser ); } Alert.alert('Error', 'Failed to update'); }, // 成功・失敗どちらでも再検証 onSettled: (data, error, variables) => { queryClient.invalidateQueries({ queryKey: getGetUserQueryKey(variables.userId) }); }, }); return (/* ... */); }
6.4 無限スクロール(Infinite Loading)
SWR
import useSWRInfinite from 'swr/infinite'; import { useSWRNativeRevalidate } from '@nandorojo/swr-react-native'; function UserList() { const getKey = (pageIndex: number, previousPageData: User[] | null) => { if (previousPageData && previousPageData.length === 0) return null; return `/api/users?page=${pageIndex}&limit=20`; }; const { data, size, setSize, isLoading, isValidating, mutate, } = useSWRInfinite<User[]>(getKey, fetcher); // React Native 対応 useSWRNativeRevalidate({ mutate }); const users = data ? data.flat() : []; const isLoadingMore = isLoading || (size > 0 && data && typeof data[size - 1] === 'undefined'); const isEmpty = data?.[0]?.length === 0; const isReachingEnd = isEmpty || (data && data[data.length - 1]?.length < 20); return ( <FlatList data={users} renderItem={({ item }) => <UserItem user={item} />} keyExtractor={(item) => item.id} onEndReached={() => { if (!isReachingEnd && !isLoadingMore) { setSize(size + 1); } }} onEndReachedThreshold={0.5} ListFooterComponent={isLoadingMore ? <ActivityIndicator /> : null} /> ); }
React Query
import { useInfiniteQuery } from '@tanstack/react-query'; import { useGetUsersInfinite, getGetUsersInfiniteQueryKey } from '@/api/generated'; function UserList() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading, refetch, } = useGetUsersInfinite( { limit: 20 }, { getNextPageParam: (lastPage, allPages) => { return lastPage.length === 20 ? allPages.length : undefined; }, } ); const users = data?.pages.flat() ?? []; return ( <FlatList data={users} renderItem={({ item }) => <UserItem user={item} />} keyExtractor={(item) => item.id} onEndReached={() => { if (hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }} onEndReachedThreshold={0.5} refreshControl={ <RefreshControl refreshing={isLoading} onRefresh={refetch} /> } ListFooterComponent={isFetchingNextPage ? <ActivityIndicator /> : null} /> ); }
6.5 条件付きフェッチ
SWR
// null を渡すとフェッチしない const { data } = useGetUser(isLoggedIn ? userId : null); // または options で制御 const { data } = useGetUser(userId, { // 条件を満たさない場合はフェッチしない isPaused: () => !isLoggedIn, });
React Query
// enabled オプションで制御 const { data } = useGetUser(userId, { enabled: isLoggedIn && !!userId, });
7. 移行時のチェックリスト
SWR を React Native で継続使用する場合
- [ ]
@nandorojo/swr-react-nativeまたは手動設定でrevalidateOnFocus/revalidateOnReconnectを有効化 - [ ] キャッシュ永続化の実装(AsyncStorage または MMKV)
- [ ] Orval 設定で
useSWRInfinite/useSWRMutationを有効化 - [ ] カスタムフェッチャーで React Native 環境対応(BASE_URL 等)
- [ ] Android エミュレータの localhost 問題に対応(
10.0.2.2)
SWR → React Query に移行する場合
| SWR | React Query | 備考 |
|---|---|---|
useSWR |
useQuery |
ほぼ同じ |
useSWRMutation |
useMutation |
返り値が異なる |
useSWRInfinite |
useInfiniteQuery |
API が異なる |
mutate(key) |
invalidateQueries({ queryKey }) |
キー指定方法が異なる |
SWRConfig |
QueryClientProvider |
Provider の構成 |
isLoading |
isLoading |
同じ |
isValidating |
isFetching |
名前が異なる |
error |
error |
同じ |
移行時の追加実装
| 機能 | SWR での対応 | React Query での対応 |
|---|---|---|
| DevTools | 自前で実装 | @tanstack/react-query-devtools |
| キャッシュ永続化 | 自前実装 or swr-sync-native-storage |
@tanstack/query-async-storage-persister |
| React Native Focus | @nandorojo/swr-react-native |
設定不要(ほぼ動作) |
| Optimistic Update | mutate の options |
onMutate + onError |
| Query 無効化 | mutate(key) |
invalidateQueries |
参考資料
公式ドキュメント
- SWR - React Native
- SWR - Cache
- SWR - Pagination
- Orval - SWR Guide
- Orval - Configuration Output
- TanStack Query - AsyncStorage Persister
- TanStack Query - Optimistic Updates
ライブラリ・パッケージ
比較記事
- React Query vs SWR Comparison
- SWR vs React Query: Choosing the Right Data Fetching Library
- React Query vs TanStack Query vs SWR: A 2025 Comparison
React Native 固有
- @react-native-community/netinfo
- @react-native-async-storage/async-storage
- MMKV - React Query Wrapper
まとめ
| 観点 | SWR | React Query |
|---|---|---|
| React Native 追加設定 | 必須(focus/reconnect) | ほぼ不要 |
| キャッシュ永続化 | 自前実装 or 外部パッケージ | 公式プラグイン |
| Orval サポート | ✅ | ✅ |
| DevTools | ❌ | ✅ |
| Optimistic Update | 基本的 | 充実 |
| バンドルサイズ | 小さい(~5KB) | 大きめ(~16KB) |
| 学習コスト | 低い | やや高い |
結論
- SWR を継続 → React Native 固有の設定とキャッシュ永続化の実装が必要。軽量さを重視する場合に選択。
- React Query に移行 → React Native での追加設定が少なく、公式の永続化プラグインや DevTools が利用可能。機能が豊富で良い。サクッと始めたいならこちらが良いかも
Orval はどちらもサポートしているため、プロジェクトの要件に応じて選択するのがよ誘う。
