第10章 React Nativeでデータを扱う
ローカル状態からグローバル状態、TanStack Queryでのフェッチまでを整理し、モバイル特有の永続化とトークンの置き場所に重点を置きます。
本章では画面の中で扱うデータの話を整理します。Webと共通する部分(コンポーネントの状態、サーバーからのデータフェッチ)はサラッと触れるにとどめ、モバイル特有の関心ごとである永続化に紙幅を割きます。

データの利用範囲、出どころ、アプリ再起動後にも残すかどうかを分けて考えると、置き場所を選びやすくなります。機密情報は通常の永続化領域と分けます。

ローカルな状態 ― useStateとuseReducer
コンポーネントの中で完結する一時的な状態は、Webと同様useStateで十分です。
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
function Counter() {
const [count, setCount] = useState(0);
return (
<View style={{ padding: 16, gap: 8 }}>
<Text>{count}</Text>
<Pressable
onPress={() => setCount((c) => c + 1)}
style={{ padding: 12, backgroundColor: "#007aff", borderRadius: 8 }}
>
<Text style={{ color: "#fff", textAlign: "center" }}>+1</Text>
</Pressable>
</View>
);
}
複雑な状態遷移にはuseReducer、複数コンポーネント間で値を共有したい範囲が小さければContext API ― この辺りもWebと同じ発想で問題ありません。
グローバルな状態 ― ZustandやJotaiなど
アプリ全体で共有したい状態(ログインユーザー、テーマ設定、UIモードなど)があれば、状態管理ライブラリを使います。Web側で使い慣れているライブラリのほとんどはReact Nativeでもそのまま動きます。
代表的なものを挙げると、
- Zustand ― シンプルなAPI、軽量、TypeScript相性がよい
- Jotai ― atomベース、細粒度な再レンダリング制御
- Redux Toolkit ― 大規模・チーム開発で安定の選択肢
- Valtio ― proxyベース、書き心地がmutable風
選び方の基準はWeb開発と同じです。チームで使い慣れているもの、プロジェクト規模に合うもの、を選んでください。本書では特定のライブラリには依存しないように説明していきますが、後続の実践編ではシンプルさを優先してZustandを使う想定です。
import { create } from "zustand";
import { Pressable, Text } from "react-native";
type CounterStore = {
count: number;
increment: () => void;
};
export const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
// 使う側
function Counter() {
const { count, increment } = useCounterStore();
return (
<Pressable onPress={increment} style={{ padding: 12 }}>
<Text>カウント: {count}</Text>
</Pressable>
);
}
サーバーデータのフェッチ ― TanStack Query
APIサーバーからデータを取ってくる、という用途にはTanStack Query(旧React Query)が事実上のデファクトです。Web開発と同じインターフェースで、React Nativeでも使えます。
import { useQuery } from "@tanstack/react-query";
import { Text, View } from "react-native";
type User = { id: string; name: string };
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: async (): Promise<User> => {
const res = await fetch(`https://api.example.com/users/${userId}`);
return res.json();
},
});
if (isLoading) return <Text>読み込み中...</Text>;
if (error) return <Text>エラーが発生しました</Text>;
return <Text>{data?.name}</Text>;
}
TanStack Queryを使う利点は、
- 自動キャッシュとリクエストの重複排除
- 画面に戻ってきた時の自動再取得
- ネットワーク状態を踏まえた制御
- ミューテーション(POST/PUT/DELETE)の状態管理
など多岐にわたります。モバイルでは特に「画面遷移と再表示が頻繁に起きる」ため、自動キャッシュの恩恵は大きいです。
なお、React Nativeで使う場合は、
- アプリがバックグラウンドから戻ってきた時の自動再取得(
focusManager) - ネットワーク状態の検知(
onlineManager+@react-native-community/netinfo) AppStateと連携した一時停止/再開
といったReact Native固有の設定がいくつかあります。詳しくはTanStack Query公式ドキュメントのReact Native向けページを参照してください。
そのほかの詳しい使い方は、Expo公式Skillsのnative-data-fetchingが良い教材になります。エラーハンドリング、楽観的更新、無限スクロールなどの定石が一通りまとまっています。
fetch APIの注意点
React Nativeにもfetchが標準で用意されていますが、Webのfetchと完全に同じではありません。たとえば、
FormDataを使ったファイルアップロードの扱いが少し違う(ファイルパスやMIMEタイプの指定方法など)- Cookieの永続化や同一オリジンの概念がブラウザほど厳密でない
- 一部のレスポンスヘッダの取り扱いがプラットフォーム間で異なることがある
実機で一番つまずくのはFormDataまわりです。WebだとFileオブジェクトをそのまま渡せますが、React Nativeではuri/name/typeの3点セットで指定します。
import * as ImagePicker from "expo-image-picker";
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ["images"] });
if (result.canceled) return;
const asset = result.assets[0];
const form = new FormData();
form.append("file", {
uri: asset.uri,
name: asset.fileName ?? "upload.jpg",
type: asset.mimeType ?? "image/jpeg",
} as any);
await fetch("https://example.com/upload", { method: "POST", body: form });
as anyが必要なのは、TypeScriptのFormData型がWeb前提で書かれているためです。実行時はネイティブ側で正しく解釈されます。
通常のJSON APIを叩く範囲では困らないことが多いですが、ファイルアップロードや認証絡みの処理を組む際は、必ず実機で挙動を確認してください。
永続化 ― ここがモバイル特有
WebでもlocalStorage、IndexedDBはありましたが、モバイルアプリではユーザーが「アプリを閉じても次回起動時に状態が残っている」ことを当然と期待します。永続化の重要性はWebよりずっと高いです。
選択肢は主に2つです。
AsyncStorage ― 標準的な選択肢
@react-native-async-storage/async-storageは、キーバリュー型のストレージです。WebのlocalStorageに最も近い感覚で使えます。
npx expo install @react-native-async-storage/async-storage
import AsyncStorage from "@react-native-async-storage/async-storage";
// 保存
await AsyncStorage.setItem("user-name", "kazutoyo");
// 読み出し
const name = await AsyncStorage.getItem("user-name");
// 削除
await AsyncStorage.removeItem("user-name");
非同期APIで、すべてawaitが必要です。文字列しか保存できないので、オブジェクトを入れる場合はJSON.stringifyで文字列化します。
シンプルさが魅力で、保存量が少ない・頻度が低い用途には十分です。
MMKV ― 高速な選択肢
react-native-mmkvは、Tencent製のMMKVをReact Native向けにラップしたライブラリです。
npx expo install react-native-mmkv
import { MMKV } from "react-native-mmkv";
const storage = new MMKV();
// 同期的に保存・読み出しができる
storage.set("user-name", "kazutoyo");
const name = storage.getString("user-name");
最大の特徴は同期APIで、awaitなしでサクッと値を読み書きできることです。AsyncStorageと比べて圧倒的に速く、頻繁に読み書きする用途(設定値、フィルタ条件、UIの状態など)で威力を発揮します。
ただし、MMKVを使うにはネイティブモジュールを含むため、Expo Goでは動かず、Development Buildへの切り替えが必要です。
選び方
本書の実践編で作るTODOアプリは、WindowsやLinuxの読者もExpo Goのまま動かせるよう、まずはAsyncStorageで進めます。「設定値の頻繁な書き換えが重い」「同期APIのほうがコードがすっきりする」と感じてきた段階で、MMKVへ切り替えてDev Clientに移行する、という順序で扱うのが安全です。
選び方の目安としては、
- 軽い設定値、保存量が少ない、Expo Goで動かしたい → AsyncStorage
- パフォーマンスを重視、頻繁な読み書き、Dev Clientは許容できる → MMKV
両者を併用することもできます。ログイン状態のような重要だが頻度の低い情報はAsyncStorage、UI設定のような頻度の高い情報はMMKV、というように使い分けるのも一つの考え方です。
状態管理ライブラリと永続化の組み合わせ
ZustandやJotaiにはストレージへの永続化を支援する仕組み(persist middlewareなど)が用意されています。これらを使えば、ストアの値が変わったタイミングで自動的にAsyncStorage/MMKVに書き出してくれます。
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
type ThemeStore = {
theme: "light" | "dark";
setTheme: (theme: "light" | "dark") => void;
};
export const useThemeStore = create<ThemeStore>()(
persist(
(set) => ({
theme: "light",
setTheme: (theme) => set({ theme }),
}),
{
name: "theme-storage",
storage: createJSONStorage(() => AsyncStorage),
}
)
);
「アプリを閉じてもテーマ設定が残る」「次回起動時に最後の状態から再開する」といった挙動を、コンポーネント側に何も書かずに実現できます。
セキュリティとストレージ
モバイルアプリはユーザーの端末上で動くので、Webと違って「ブラウザサンドボックスの中」という前提が無くなります。シークレットや個人情報の扱いはWebよりも気を使う領域なので、最低限の指針をまとめておきます。
認証トークンはexpo-secure-storeに置く
トークンやパスワードなど、漏れたら困る情報は、AsyncStorageやMMKVではなくexpo-secure-storeに保存します。AsyncStorage/MMKVはアプリのサンドボックス内にファイルとして書かれるだけで、Root化/Jailbreakした端末からは中身を取り出されるリスクがあります。
npx expo install expo-secure-store
import * as SecureStore from "expo-secure-store";
await SecureStore.setItemAsync("auth-token", "secret-token");
const token = await SecureStore.getItemAsync("auth-token");
iOSではKeychain、AndroidではAndroid Keystoreで暗号化されたSharedPreferencesに格納されるので、OS提供の暗号化領域に置けます。「ログイン後に取得した認証トークンは原則SecureStore」と覚えておいてください。
クライアントバンドルにシークレットを置かない
リリースしたアプリのバンドルは、解析しようと思えば中身を読み出せます。ソースコード内に書かれたAPIキーやシークレットは「公開されているもの」と同じ扱いです。
| 想定するシークレット | 置く場所 |
|---|---|
| 公開してよい設定値(API URL、Firebaseクライアント設定) | バンドルに焼き込んでよい(process.env.EXPO_PUBLIC_*) |
| サーバ間通信用の鍵、Firebase Service Account JSON | サーバ側に置く。クライアントには絶対に置かない |
| ストア提出やSentry連携などビルド時のみ要る鍵 | EAS環境変数(sensitive/secret、第18章で扱います) |
「.envに書いてあるから安全」というWebの感覚は、モバイルでは通用しません。クライアントから叩くAPIは、サーバ側に薄い中継層を置いて鍵を隠す、というのが基本パターンです。
通信はHTTPSが前提
iOSのApp Transport Security(ATS)とAndroidのNetwork Security Configは、デフォルトでhttp://通信をブロックします。本番のエンドポイントは必ずHTTPSにしてください。開発時にローカルサーバへ繋ぎたい場合だけapp.jsonで例外を追加します。
機密度の高い通信(金融・医療など)では、サーバの証明書をクライアント側でも検証する証明書ピンニングを入れる場面もあります。Expo Modulesの自作かreact-native-ssl-pinning等のサードパーティで対応します。
Deep linkは「外からの入力」として扱う
Expo Routerでディープリンクを受け取ると、URLのパスやクエリがそのままアプリに流れ込みます。これはWebで言う?id=...のURLパラメータと同じく、外部から渡された入力です。
import { useLocalSearchParams } from "expo-router";
import { z } from "zod";
const { id } = useLocalSearchParams<{ id: string }>();
const parsed = z.string().uuid().safeParse(id);
if (!parsed.success) return;
// 検証OKのときだけ後続処理に進む
zod等で型を絞り込んでから使うクセをつけておくと、想定外のパラメータでクラッシュしたり、不正なIDで他人のリソースを引いてしまうリスクを減らせます。
重要操作の前にローカル認証を挟む
決済、設定変更、機密情報の表示などの操作の前に、端末のロックと同じ認証(指紋・Face ID)を挟むと安心感が上がります。expo-local-authenticationを使います。
import * as LocalAuthentication from "expo-local-authentication";
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "本人確認",
});
if (!result.success) return;
// 認証OKのときだけ重要操作を進める
iOSはFace ID/Touch ID、Androidは生体認証(指紋・顔)やPIN/パターンが使われます。サーバ側のセッション管理と二重で守る形にしておくのが定石です。
本章のまとめ
- ローカル状態はWeb同様にuseState/useReducerで十分
- グローバル状態はZustand、Jotaiなど。Webと同じ感覚でOK
- データフェッチはTanStack Queryが定石
- 永続化はAsyncStorage(シンプル)とMMKV(高速・同期)を使い分け
- 認証トークンはexpo-secure-storeへ。バンドルにシークレットを直接書かない
- 通信はHTTPS前提、Deep linkは外部入力として検証、重要操作はexpo-local-authentication
次章では、ネイティブ機能 ― カメラ、画像ピッカー、位置情報、通知などをサラッと使う方法を見ます。