React Nativeでアプリを作り始めたWeb開発者がまず安心するのが、「fetchがそのまま使える」という事実です。
// ブラウザでもReact Nativeでも、書き方は同じ
const response = await fetch('https://api.example.com/users');
const data = await response.json();
APIは同じです。しかし、裏側で動いている仕組みがまったく違います。ブラウザのfetchはブラウザエンジン(Chromium, WebKit)が実装したWeb標準のFetch APIです。React Nativeのfetchは、JavaScriptのPolyfillを通じてOSのネイティブHTTPクライアントを呼び出しています。
この違いを理解していないと、「ブラウザでは動くのにアプリでは動かない」「なぜCORSエラーが出ないのか」「Cookieが消えた」といった問題にぶつかります。
まず、ブラウザとReact Nativeでfetchが実行される流れを見てみましょう。
この図が示す最大のポイントは2つです:
fetchは、内部的にXMLHttpRequestのPolyfillを使って実装されています。このXHRもまた、JavaScriptの標準XHRではなく、ネイティブ側に実装されたRCTNetworking(iOS)/NetworkingModule(Android)を呼び出すPolyfillです。つまり、fetch → XHR Polyfill → Native HTTP Client という2段階のラッピングが行われています。
Web開発者にとって最大の驚きはこれでしょう。React NativeにはCORSがありません。
CORSを理解するには、なぜブラウザにCORSが必要なのかを知る必要があります。
ブラウザには「オリジン」という概念があります。evil-site.comのJavaScriptからbank.example.comにリクエストを送ることは「クロスオリジンリクエスト」であり、ブラウザはこれをデフォルトでブロックします。サーバーがAccess-Control-Allow-Originヘッダーで明示的に許可しない限り、レスポンスはJavaScriptから読めません。
ではReact Nativeではどうでしょうか?
React Nativeアプリはcurlコマンドと同じ立場です。「どのオリジンから来たか」という概念がそもそもないので、サーバーがCORSヘッダーを返さなくても問題なくレスポンスを受け取れます。
// ブラウザでは CORS エラーになるリクエストも、
// React Native では問題なく成功する
const res = await fetch('https://api.third-party.com/data');
const data = await res.json(); // 普通に読める
Webの世界では、サーバーがSet-Cookieヘッダーを返せば、ブラウザが自動的にCookieを保存し、次回のリクエストに自動で付与してくれます。認証セッションの維持はこの仕組みに完全に依存しています。
React Nativeでは、この「自動」がプラットフォームによって挙動が異なります。
| 項目 | ブラウザ | React Native (iOS) | React Native (Android) |
|---|---|---|---|
| Set-Cookieの自動保存 | 常に自動 | NSHTTPCookieStorageが管理 | OkHttpのCookieJarで管理(デフォルトでは非永続) |
| リクエストへの自動付与 | 同一オリジンなら常に自動 | 動作するが不安定な場合あり | 動作するが設定が必要な場合あり |
| HttpOnly Cookie | JSからアクセス不可(安全) | ネイティブ層で管理されるためJSから見えない | 同上 |
| SameSite属性 | ブラウザが強制 | 概念なし(オリジンがないため) | 同上 |
| アプリ再起動後の永続化 | ブラウザが管理(期限まで保持) | NSHTTPCookieStorageで永続化 | デフォルトでは消える場合がある |
// ブラウザでは credentials の設定がCORS + Cookie挙動を決める
fetch('https://api.example.com/me', {
credentials: 'include' // クロスオリジンでもCookie送信
});
fetch('https://api.example.com/me', {
credentials: 'same-origin' // 同一オリジンのみCookie送信(デフォルト)
});
fetch('https://api.example.com/me', {
credentials: 'omit' // Cookie一切送信しない
});
// React Native では credentials の挙動がプラットフォーム依存
// 多くの場合、この設定は期待通りに動作しない
fetch('https://api.example.com/me', {
credentials: 'include'
});
// → iOS: NSURLSessionの設定に依存
// → Android: OkHttpの設定に依存
// → 動いたり動かなかったりする
Authorization: Bearer <token> ヘッダーを自前で付与し、トークンはexpo-secure-storeやreact-native-keychainで安全に保存します。Cookieのプラットフォーム差異に悩むことがなくなります。
ブラウザのfetchにはタイムアウト機能がありません(AbortControllerで自前実装する必要があります)。React Nativeも同様ですが、裏側のネイティブHTTPクライアントにはOS固有のタイムアウト設定があります。
// ブラウザ・React Native 共通で動作するタイムアウト実装
async function fetchWithTimeout(url, options = {}, timeoutMs = 10000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
return response;
} finally {
clearTimeout(timeoutId);
}
}
このコードはブラウザ・React Native共通で動きます。ただし注意点があります:
| 項目 | ブラウザ | React Native |
|---|---|---|
| AbortController | 標準サポート | Polyfillで対応(React Native 0.60+) |
| abort時の挙動 | 即座にネットワーク接続を切断 | ネイティブ側のキャンセルタイミングにラグがある場合がある |
| OS側のデフォルトタイムアウト | ブラウザ依存(通常300秒程度) | iOS: NSURLSession 60秒 / Android: OkHttp 10秒 |
最近のWeb開発で頻出するReadableStream。ChatGPTのように文字が流れてくるUIを作るときに使います。
const response = await fetch('https://api.example.com/stream');
const reader = response.body.getReader(); // ReadableStream
while (true) {
const { done, value } = await reader.read();
if (done) break;
// value はチャンクごとに届く
console.log(new TextDecoder().decode(value));
}
React Nativeでは、このresponse.body(ReadableStream)の対応状況がブラウザとは大きく異なります。
| 機能 | ブラウザ | React Native |
|---|---|---|
| response.body (ReadableStream) | 標準サポート | React Native 0.71+ で部分対応 Hermes + New Architecture が推奨 |
| TextDecoderStream | 標準サポート | 非対応(Polyfill必要) |
| Server-Sent Events (SSE) | EventSource API で対応 | EventSource非対応、ライブラリ必要 |
// 方法1: react-native-sse ライブラリを使う
import EventSource from 'react-native-sse';
const es = new EventSource('https://api.example.com/stream');
es.addEventListener('message', (event) => {
console.log(event.data);
});
// 方法2: XMLHttpRequest の onprogress を使う(低レベル)
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/stream');
xhr.onprogress = () => {
// xhr.responseText に都度追加される
console.log(xhr.responseText);
};
xhr.send();
ファイルアップロードの実装は、ブラウザとReact Nativeで書き方が大きく異なります。
// File APIを使う
const input = document
.querySelector('input[type=file]');
const file = input.files[0];
const form = new FormData();
form.append('avatar', file);
fetch('/upload', {
method: 'POST',
body: form
});
// URIベースのオブジェクトを使う
const form = new FormData();
form.append('avatar', {
uri: 'file:///path/to/photo.jpg',
type: 'image/jpeg',
name: 'photo.jpg',
});
fetch('/upload', {
method: 'POST',
body: form
});
ブラウザではFileオブジェクト(<input type="file">から取得)をそのままFormDataに追加します。React NativeにはFile APIがないので、代わりにuri, type, nameを持つオブジェクトを渡します。
React NativeのFormData実装はこのオブジェクトを認識して、uriで指定されたファイルをネイティブ層で読み取り、multipart/form-dataとして送信してくれます。
uriがfile://で始まっていないとiOSでエラーになることがあるtypeを省略するとAndroidでapplication/octet-streamになり、サーバー側のバリデーションに引っかかるuriを含むので、そのまま使えるケースが多いブラウザのfetchにはcacheオプションがあり、HTTPキャッシュの挙動を細かく制御できます。
fetch(url, { cache: 'no-store' }); // キャッシュを一切使わない
fetch(url, { cache: 'no-cache' }); // 毎回サーバーに再検証
fetch(url, { cache: 'force-cache' }); // 期限切れでもキャッシュ優先
fetch(url, { cache: 'default' }); // 標準的なHTTPキャッシュ動作
React Nativeでは、cacheオプションの対応状況がプラットフォームによって異なります。
| cache 値 | ブラウザ | React Native (iOS) | React Native (Android) |
|---|---|---|---|
default |
HTTPキャッシュ準拠 | NSURLRequestのcachePolicyに変換 | OkHttpのキャッシュに依存 |
no-store |
対応 | ReloadIgnoringLocalCacheDataに変換 | Cache-Control ヘッダーで代替 |
no-cache |
対応 | 部分対応 | 非対応の場合あり |
force-cache |
対応 | ReturnCacheDataElseLoadに変換 | 非対応の場合あり |
cacheオプションに頼らずHTTPヘッダーで明示するのが確実です。fetch(url, { headers: { 'Cache-Control': 'no-cache, no-store, must-revalidate' } })
ブラウザではHTTPSの証明書検証はブラウザエンジンが透過的に行います。開発者が意識することはほとんどありません。
React Nativeでは、ネイティブのHTTPクライアントがTLS検証を担当するため、いくつかの違いが生じます。
証明書ピニング(Certificate Pinning)は、React Nativeアプリで使える強力なセキュリティ機能です。サーバー証明書のフィンガープリントをアプリに埋め込み、中間者攻撃(MITM)を防ぎます。ブラウザのfetchにはこの機能はありません。
Info.plistで例外を追加する必要があります。AndroidではAndroidManifest.xmlにandroid:usesCleartextTraffic="true"を追加します。ブラウザにはこのような制約がありません。
ブラウザとReact Nativeでは、ネットワーク状態にアクセスできる情報量が大きく異なります。
navigator.onLine で接続有無のみnavigator.connection で接続タイプ(限定的)@react-native-community/netinfoimport NetInfo from '@react-native-community/netinfo';
async function smartFetch(url, options) {
const state = await NetInfo.fetch();
if (!state.isConnected) {
// オフライン → キャッシュから返す or エラーUI
throw new Error('No network connection');
}
if (state.type === 'cellular' && state.details?.cellularGeneration === '3g') {
// 3G回線 → 画像の読み込みを低画質に切り替え
url = url.replace('/full/', '/thumb/');
}
return fetch(url, options);
}
これはネイティブアプリならではの最適化です。ブラウザでは接続品質の正確な判定が難しいため、この種の実装は一般的ではありません。
| 観点 | ブラウザ fetch | React Native fetch |
|---|---|---|
| 実装 | ブラウザエンジンのネイティブ実装 | Polyfill → ネイティブHTTPクライアント |
| CORS | ブラウザが強制 | 存在しない |
| Cookie | 自動管理、SameSite対応 | プラットフォーム依存、不安定 |
| 認証推奨 | Cookie or Bearer Token | Bearer Token 一択 |
| ストリーミング | ReadableStream 完全対応 | 部分対応、ライブラリ推奨 |
| ファイルアップロード | File API + FormData | URI + {uri, type, name} オブジェクト |
| キャッシュ制御 | cache オプション完全対応 | iOS部分対応、Androidほぼ非対応 |
| TLS/証明書 | ブラウザが管理 | OS管理 + 証明書ピニング可能 |
| タイムアウト | AbortControllerで自前実装 | 同左 + OS側デフォルト値あり |
| ネットワーク状態 | 限定的(navigator.onLine) | 詳細(接続タイプ、世代、品質) |
| Service Worker | リクエストのインターセプト可能 | 存在しない |
| HTTP/2,3 | ブラウザが透過的に対応 | OS HTTPクライアントの対応次第 |
ここまでの違いを踏まえて、WebとReact Nativeの両方で動くコードを書く際のベストプラクティスをまとめます。
// Platform-agnostic な API クライアント
type ApiClientConfig = {
baseUrl: string;
getToken: () => Promise<string | null>;
timeoutMs?: number;
};
export function createApiClient(config: ApiClientConfig) {
const { baseUrl, getToken, timeoutMs = 15000 } = config;
return {
async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const token = await getToken();
try {
const res = await fetch(`${baseUrl}${path}`, {
...options,
signal: controller.signal,
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...options.headers,
},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<T>;
} finally {
clearTimeout(timeoutId);
}
},
};
}
const api = createApiClient({
baseUrl: '/api', // 相対パス(同一オリジン)
getToken: async () => localStorage.getItem('token'),
});
import * as SecureStore from 'expo-secure-store';
const api = createApiClient({
baseUrl: 'https://api.example.com', // 絶対URL(CORSなし)
getToken: async () => SecureStore.getItemAsync('token'),
});
import { Platform } from 'react-native';
type FileInput =
| File // Web
| { uri: string; type: string; name: string }; // Native
export function createUploadForm(fieldName: string, file: FileInput) {
const form = new FormData();
form.append(fieldName, file as any);
return form;
}
React Nativeでは@react-native-community/netinfoを使い、ネットワーク状態に応じた最適化を入れましょう。Webではnavigator.onLineとwindow.addEventListener('online'/'offline')で基本的な接続監視を行います。
React Nativeのfetchはブラウザのfetchと同じAPIを持っていますが、中身はまったくの別物です。これは意図的な設計で、Web開発者の学習コストを下げつつ、ネイティブの機能を活用できるようにするためのものです。
ただし、その「同じに見える」ことが逆に罠になることがあります。CORSがなくてラッキーと思う一方で、Cookieが勝手に消えたり、ストリーミングが動かなかったり。これらはすべて、裏側のアーキテクチャが違うことに起因しています。
大事なのは「ブラウザの常識をリセットする」ことです。fetchのAPIは同じでも、セキュリティモデル、認証方式、キャッシュ戦略、すべてを一度ゼロベースで考え直す。その上でコードを書けば、WebでもNativeでも安定したネットワーク通信が実現できます。