状態管理とデータ

React の常識(useState / useContext)はそのまま使える。 加えて RN 固有の事情として、サーバーデータ永続化オフライン対応を考える必要がある。

状態の3層

  1. UI ローカル状態 — そのコンポーネントだけ。useState / useReducer
  2. クライアント全体の共有状態 — テーマ、認証、設定など。Zustand / Jotai / Context。
  3. サーバー状態 — API から取ってくるデータ。React Query / SWR で管理。

「なんでも Redux」は時代遅れ。3 層に分けて、層ごとに適した道具を選ぶのが現代流。

1. UI ローカル: useState / useReducer

Web の React と同じ。説明省略。

2. クライアント共有: Zustand

2026 現在、Web/Native ともに人気が高い軽量ステートライブラリ。 Provider 不要、TS 親和性が高い、コードが短い。

npm install zustand
import { create } from "zustand"

interface AuthState {
  user: User | null
  setUser: (u: User | null) => void
  signOut: () => void
}

export const useAuth = create<AuthState>((set) => ({
  user: null,
  setUser: (user) => set({ user }),
  signOut: () => set({ user: null }),
}))
// 使う
function Profile() {
  const user = useAuth((s) => s.user)         // セレクタで購読
  const signOut = useAuth((s) => s.signOut)
  return user
    ? <Pressable onPress={signOut}><Text>サインアウト ({user.name})</Text></Pressable>
    : null
}

Zustand を永続化

persist ミドルウェアで AsyncStorage / MMKV に自動保存。

import { create } from "zustand"
import { persist, createJSONStorage } from "zustand/middleware"
import AsyncStorage from "@react-native-async-storage/async-storage"

export const useSettings = create(
  persist(
    (set) => ({
      theme: "dark",
      setTheme: (theme: string) => set({ theme }),
    }),
    {
      name: "settings",
      storage: createJSONStorage(() => AsyncStorage),
    },
  ),
)

3. サーバー状態: React Query (TanStack Query)

API データ管理のデファクトスタンダード。キャッシュ・再取得・エラーハンドリング・楽観的更新を一通り。

npm install @tanstack/react-query

セットアップ

app/_layout.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Slot } from "expo-router"

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,         // 30秒
      retry: 1,
    },
  },
})

export default function RootLayout() {
  return (
    <QueryClientProvider client={queryClient}>
      <Slot />
    </QueryClientProvider>
  )
}

fetch する

import { useQuery } from "@tanstack/react-query"

function PostList() {
  const { data, isLoading, error, refetch, isRefetching } = useQuery({
    queryKey: ["posts"],
    queryFn: () => fetch("https://api.example.com/posts").then(r => r.json()),
  })

  if (isLoading) return <ActivityIndicator />
  if (error) return <Text>エラー</Text>

  return (
    <FlatList
      data={data}
      keyExtractor={(p) => p.id}
      renderItem={({ item }) => <PostRow post={item} />}
      refreshing={isRefetching}
      onRefresh={refetch}
    />
  )
}

更新する(mutation)

import { useMutation, useQueryClient } from "@tanstack/react-query"

function NewPost() {
  const qc = useQueryClient()
  const mutation = useMutation({
    mutationFn: (post: NewPost) =>
      fetch("/api/posts", { method: "POST", body: JSON.stringify(post) }),
    onSuccess: () => {
      qc.invalidateQueries({ queryKey: ["posts"] })  // 一覧を再取得
    },
  })

  return <Button onPress={() => mutation.mutate({ title: "..." })} />
}

無限スクロール

import { useInfiniteQuery } from "@tanstack/react-query"

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ["feed"],
  queryFn: ({ pageParam }) => fetchPage(pageParam),
  initialPageParam: 0,
  getNextPageParam: (last) => last.nextCursor,
})

<FlatList
  data={data?.pages.flatMap(p => p.items) ?? []}
  onEndReached={() => hasNextPage && fetchNextPage()}
/>

永続化(Storage)

AsyncStorage(標準)

小さなキー/バリュー保存。非同期なので使う側はちょっと面倒。

npx expo install @react-native-async-storage/async-storage
import AsyncStorage from "@react-native-async-storage/async-storage"

await AsyncStorage.setItem("key", "value")
const v = await AsyncStorage.getItem("key")
await AsyncStorage.removeItem("key")

MMKV(推奨)

Facebook 製の同期で爆速な KV ストア。AsyncStorage の事実上の上位互換。 Dev Build が必要(ネイティブモジュールのため Expo Go では動かない)。

npx expo install react-native-mmkv
import { MMKV } from "react-native-mmkv"
const storage = new MMKV()

storage.set("user.id", 1)              // 同期!
storage.getNumber("user.id")
storage.set("user.name", "alice")
storage.getString("user.name")
storage.delete("user.id")

expo-secure-store

機密情報(トークン、パスワード)を OS のキーチェーンに保存。サイズ制限あり(iOS 2KB 程度)。

import * as SecureStore from "expo-secure-store"

await SecureStore.setItemAsync("token", "abc123")
const token = await SecureStore.getItemAsync("token")

SQLite(expo-sqlite

ローカル DB。複雑なデータや関係を扱う時。2026 現在は Drizzle ORM との組み合わせが人気

import * as SQLite from "expo-sqlite"

const db = await SQLite.openDatabaseAsync("app.db")
await db.execAsync(`CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)`)
await db.runAsync("INSERT INTO users (name) VALUES (?)", ["alice"])
const rows = await db.getAllAsync<{ id: number, name: string }>("SELECT * FROM users")

オフライン対応

モバイルは常にオフラインを考慮する。基本パターン:

オンライン状態

import NetInfo from "@react-native-community/netinfo"

const state = await NetInfo.fetch()
console.log(state.isConnected, state.type)

NetInfo.addEventListener((s) => {
  // ネットワーク状態が変わった
})

認証

SecureStore + Zustand + React Query の組み合わせが定石:

  1. ログイン後、トークンを SecureStore に保存
  2. Zustand に { token, user } を持つ
  3. API 呼び出しの fetch ラッパでトークンを Authorization に付ける
  4. 401 が返ったら自動でリフレッシュ or サインアウト

OAuth / Apple Sign-In / Google Sign-In

Context + Reducer の使い所

Zustand を入れたくない小規模アプリや、screen 単位で完結する状態には Context + Reducer で十分。

Jotai / Recoil(参考)

atom 単位でリアクティブに状態を管理するライブラリ。UI コンポーネント単位の状態に向く。 Zustand と比べると好みが分かれる。

状態を永続化したい / 復元したい時の判断

用途使うもの
テーマ / 言語 / 設定MMKV or AsyncStorage
認証トークンSecureStore
サーバーから取れる/再取得できるデータReact Query のキャッシュで十分
ローカルだけのリストやノートSQLite
大きなファイル(画像・動画)FileSystem(Expo SDK
状態のスナップショットを毎回Zustand persist
React Server Components や tRPC の話

Expo Router は将来的に React Server Components の対応も進める予定(Universal RSC)。
tRPC を組み合わせて型安全な API クライアントを作るパターンも増えている。
普通の JSON API + React Query が依然として実用上のメインラインだが、定期的に動向はチェック。