メインコンテンツへスキップ
  1. 投稿/

Zustand vs React Query vs AsyncStorage:どこに何を置くべきか

· loading · loading ·
仁才徳
著者
仁才徳
韓国ソウル在住のリーダー兼ソフトウェアエンジニア
目次

ReactやReact Nativeプロジェクトでよくある間違いの一つは、全部を一つのステート管理ソリューションに突っ込むことだ。APIキャッシュ、ユーザー設定、フォームステート、認証トークンを全部まとめて扱うReduxストアができあがり、ぐちゃぐちゃになる。これらは異なる種類のステートであり、異なるツールが必要だ。

Zustand、React Query、AsyncStorageはそれぞれ異なる問題を解決する。それぞれの境界を理解すれば、アーキテクチャがずっとすっきりする。

3つのステートの種類
#

ツールを選ぶ前に、自分が実際に何を管理しているのかを理解しよう。

クライアントステートはアプリのランタイムにのみ存在するデータだ。UIのトグル、選択中のタブ、フォーム入力、モーダルの表示/非表示、テーマ設定など。アプリが動いている間だけ存在し、サーバーからは来ない。

サーバーステートはリモートサーバーに存在するデータで、アプリにはそのローカルコピーがあるだけだ。ユーザープロフィール、商品一覧、通知、フィードデータなど。古くなる可能性があり、再取得が必要で、複数のクライアントが異なるバージョンを見ていることもある。

永続ステートはアプリの再起動後も残す必要があるデータだ。認証トークン、オンボーディング完了フラグ、キャッシュされたユーザー設定、オフラインデータなど。デバイス自体に保存される。

ほとんどのアプリは3つすべてを持っている。間違いは一つのツールで全部をやろうとすることだ。

Zustand:クライアントステートを正しく扱う
#

Zustandは軽量なReact用ステート管理ライブラリだ。プロバイダーも不要、ボイラープレートも不要、コンテキストラッパー地獄もない。ストアを作って、コンポーネントで使う。それだけだ。

Zustandを使うべきとき
#

  • 複数のコンポーネントが必要とするUIステート(サイドバーの開閉、アクティブなフィルター、選択中のアイテム)
  • APIから来ないアプリレベルのステート(テーマ、言語、起動時に読み込むフィーチャーフラグ)
  • 複雑なクライアントサイドロジック(ショッピングカートの計算、マルチステップのフォームウィザード)
  • 同期的かつ予測可能に更新する必要があるステート

基本的な例
#

import { create } from 'zustand'

interface AppState {
  theme: 'light' | 'dark'
  sidebarOpen: boolean
  selectedFilters: string[]
  setTheme: (theme: 'light' | 'dark') => void
  toggleSidebar: () => void
  setFilters: (filters: string[]) => void
}

const useAppStore = create<AppState>((set) => ({
  theme: 'light',
  sidebarOpen: false,
  selectedFilters: [],
  setTheme: (theme) => set({ theme }),
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
  setFilters: (filters) => set({ selectedFilters: filters }),
}))

コンポーネントでの使い方:

function Sidebar() {
  const { sidebarOpen, toggleSidebar } = useAppStore()

  if (!sidebarOpen) return null

  return (
    <div className="sidebar">
      <button onClick={toggleSidebar}>Close</button>
      {/* sidebar content */}
    </div>
  )
}

Zustandが向いていないもの
#

ZustandをAPIレスポンスのキャッシュに使うな。APIコールのたびにZustandストアに書き込んで、コンポーネントがAPIの代わりにストアから読むプロジェクトを見たことがある。結局、キャッシュの無効化、ローディング状態、エラーハンドリング、再取得、ページネーションを自分で再実装することになる - React Queryがタダでくれるものばかりだ。

アプリの再起動後も残す必要があるデータにZustandを使うな。Zustandのステートはメモリ上にある。アプリを閉じたら消える。永続化ミドルウェアを追加することはできるが(後述)、その時点でAsyncStorageの領域に踏み込んでいるので、意図的にやるべきだ。

React Query:サーバーステートを楽に扱う
#

React Query(TanStack Query)はリモートデータのライフサイクル全体を管理する。取得、キャッシュ、同期、更新、ガベージコレクション。サーバーのデータを、自分が所有するステートとしてではなく、新鮮に保つ必要があるキャッシュとして扱う。

React Queryを使うべきとき
#

  • APIから取得するあらゆるデータ
  • 複数のコンポーネントが同じエンドポイントから必要とするデータ(リクエストを自動で重複排除してくれる)
  • ページネーションや無限スクロールのデータ
  • バックグラウンドでの再取得が必要なデータ(ユーザーがアプリに戻ってきたとき、データが古くなっているかもしれない)
  • 楽観的更新(UIを即座に更新し、サーバーが拒否したらロールバックする)

基本的な例
#

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

function useUser(userId: string) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
    staleTime: 5 * 60 * 1000, // 5分間はフレッシュとみなす
  })
}

function useUpdateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (data: { id: string; name: string }) =>
      fetch(`/api/users/${data.id}`, {
        method: 'PATCH',
        body: JSON.stringify(data),
      }),
    onSuccess: (_, variables) => {
      queryClient.invalidateQueries({ queryKey: ['user', variables.id] })
    },
  })
}

コンポーネントでの使い方:

function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useUser(userId)
  const updateUser = useUpdateUser()

  if (isLoading) return <Spinner />
  if (error) return <ErrorMessage error={error} />

  return (
    <div>
      <h1>{user.name}</h1>
      <button
        onClick={() => updateUser.mutate({ id: userId, name: 'New Name' })}
        disabled={updateUser.isPending}
      >
        Update Name
      </button>
    </div>
  )
}

タダで手に入るもの
#

  • 自動重複排除: 5つのコンポーネントが同じユーザーをリクエストしても、ネットワークリクエストは1回だけ。
  • バックグラウンド再取得: ウィンドウがフォーカスを取り戻したとき、ネットワークが再接続したときにデータを再取得する。
  • キャッシュ管理: 古いデータは自動的にガベージコレクションされる。
  • ローディングとエラーの状態: すべてのクエリが isLoadingisErrordata などを提供する。
  • リトライロジック: 失敗したリクエストは指数バックオフで自動的にリトライされる。
  • 楽観的更新: サーバーの確認前にUIを更新し、失敗したらロールバックする。

React Queryが向いていないもの
#

React Queryをクライアントのみのステートに使うな。データがサーバーから来ないなら、React Queryは無駄な複雑さを加えるだけだ。モーダルの開閉状態にキャッシュの無効化やリフェッチ間隔は必要ない。

React Queryを長期的な永続化レイヤーとして使うな。キャッシュはメモリ上にある。アプリが再起動すると、キャッシュは空になり全部再取得になる。キャッシュを永続化することはできる(React Nativeアプリではそうすべきだ)が、真のソースは常にサーバーだ。

AsyncStorage:プラットフォーム横断の永続ステート
#

AsyncStorageはReact Native用のキーバリューストレージソリューションだ。Web上での同等品は localStorage(同期)や IndexedDB(非同期、より高機能)だ。同じ考え方 - デバイス上に保存することでアプリの再起動後も残るデータだ。

プラットフォームごとの実装
#

ストレージのバックエンドはプラットフォームごとに異なるが、@react-native-async-storage/async-storage を使えばAPIは同じだ。

Android: 内部的にはSQLiteを RKStorage 経由で使用する。データはアプリの内部ストレージディレクトリにあるSQLiteデータベースに保存される。高速で信頼性が高く、実質的なサイズ制限はない(Androidがアプリごとのストレージ制限を課すことはあるが)。データはアプリにサンドボックス化されており、他のアプリはアクセスできない。

iOS: 小さな値には NSUserDefaults、大きな値にはシリアライズされたデータファイルを使用する。Androidと同様に、データはアプリのコンテナ内にサンドボックス化されている。Appleは NSUserDefaults にハードリミットを課していないが、ベストプラクティスとしては個々の値を数百KB以下に抑えることだ。大きなデータにはWatermelonDBやRealmなどの本格的なデータベースの使用を検討しよう。

Web (React Native Web / Expo Web): localStorage にフォールバックし、ブラウザに応じて約5-10MBの制限がある。Web専用のReactアプリなら、localStorage を直接使うか、大きなデータセットには idb-keyval のようなラッパーで IndexedDB を使える。

PlatformBackendSize LimitLocation
AndroidSQLite (RKStorage)~6 MB default (configurable)App internal storage
iOSNSUserDefaults / filesNo hard limit (keep values small)App sandbox container
WeblocalStorage~5-10 MB (browser-dependent)Browser origin storage

AsyncStorageを使うべきとき
#

  • 認証トークンとセッションデータ
  • 永続化すべきユーザー設定(言語、テーマ、通知設定)
  • オンボーディング完了フラグ
  • オフラインファースト体験のためのキャッシュデータ
  • アプリの再起動後も残す必要がある小さなキーバリューデータ

基本的な例
#

import AsyncStorage from '@react-native-async-storage/async-storage'

// 値を保存する
await AsyncStorage.setItem('auth_token', token)

// 値を読み取る
const token = await AsyncStorage.getItem('auth_token')

// オブジェクトを保存する(シリアライズが必要)
await AsyncStorage.setItem('user_preferences', JSON.stringify({
  theme: 'dark',
  language: 'en',
  notifications: true,
}))

// オブジェクトを読み取る
const prefs = JSON.parse(await AsyncStorage.getItem('user_preferences') ?? '{}')

// 値を削除する
await AsyncStorage.removeItem('auth_token')

// 全部消す(注意して使うこと)
await AsyncStorage.clear()

Web上での同等品
#

Web専用のReactアプリならAsyncStorageは不要だ。シンプルなキーバリューペアには localStorage を使おう:

// 同期的 - メインスレッドをブロックするが、小さなデータなら問題ない
localStorage.setItem('theme', 'dark')
const theme = localStorage.getItem('theme')

// 構造化データの場合
localStorage.setItem('user', JSON.stringify({ name: 'Jared', role: 'admin' }))
const user = JSON.parse(localStorage.getItem('user') ?? '{}')

大きなデータセットにはWeb上では IndexedDB を使おう:

import { get, set, del } from 'idb-keyval'

await set('large-dataset', hugeArray)
const data = await get('large-dataset')
await del('large-dataset')

AsyncStorageが向いていないもの
#

AsyncStorageをメインのデータストアとして使うな。キーバリューストアであって、データベースではない。リレーショナルデータ、数千件のアイテムの配列、インデックスやクエリが必要なものを保存するなら、SQLite(expo-sqlite 経由)、WatermelonDB、Realmを使おう。

暗号化なしでAsyncStorageに機密データを保存するな。AndroidとiOSでは、デバイスがroot化/脱獄されていればAsyncStorageのデータにアクセスできてしまう。機密トークンには expo-secure-storereact-native-keychain を代わりに使おう。

それぞれがどう連携するか
#

実際のアプリでは、この3つのツールが連携して動く。

例:ユーザー認証フロー
#

// 1. AsyncStorage: 認証トークンを永続化する
import AsyncStorage from '@react-native-async-storage/async-storage'

async function saveToken(token: string) {
  await AsyncStorage.setItem('auth_token', token)
}

async function getToken(): Promise<string | null> {
  return AsyncStorage.getItem('auth_token')
}

// 2. Zustand: メモリ上で認証ステートを追跡する
import { create } from 'zustand'

interface AuthState {
  isAuthenticated: boolean
  token: string | null
  setAuth: (token: string) => void
  clearAuth: () => void
}

const useAuthStore = create<AuthState>((set) => ({
  isAuthenticated: false,
  token: null,
  setAuth: (token) => set({ isAuthenticated: true, token }),
  clearAuth: () => set({ isAuthenticated: false, token: null }),
}))

// 3. React Query: トークンを使ってユーザープロフィールを取得する
function useCurrentUser() {
  const token = useAuthStore((s) => s.token)

  return useQuery({
    queryKey: ['currentUser'],
    queryFn: () =>
      fetch('/api/me', {
        headers: { Authorization: `Bearer ${token}` },
      }).then(res => res.json()),
    enabled: !!token, // トークンがあるときだけ取得する
  })
}

アプリ起動時:

// アプリの初期化
async function initializeApp() {
  const token = await getToken() // AsyncStorageから読み取る
  if (token) {
    useAuthStore.getState().setAuth(token) // 高速アクセスのためにZustandに入れる
    // React Queryが自動的にユーザープロフィールを取得する
  }
}

例:テーマ設定の永続化
#

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

// Zustandの永続化ミドルウェアがギャップを埋める
const useThemeStore = create(
  persist(
    (set) => ({
      theme: 'light' as 'light' | 'dark',
      toggleTheme: () =>
        set((state) => ({
          theme: state.theme === 'light' ? 'dark' : 'light',
        })),
    }),
    {
      name: 'theme-storage',
      storage: createJSONStorage(() => AsyncStorage), // React Native
      // storage: createJSONStorage(() => localStorage), // Web
    }
  )
)

Zustandがランタイムのステートを管理し、永続化ミドルウェアが自動的にAsyncStorage(Web上ではlocalStorage)に同期する。コンポーネントは永続化レイヤーのことを知る必要も気にする必要もない。

例:オフラインファーストのデータ
#

import { useQuery } from '@tanstack/react-query'
import AsyncStorage from '@react-native-async-storage/async-storage'

function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: async () => {
      try {
        const res = await fetch('/api/products')
        const data = await res.json()

        // オフライン用にAsyncStorageにキャッシュする
        await AsyncStorage.setItem('cached_products', JSON.stringify(data))

        return data
      } catch (error) {
        // ネットワーク失敗 - キャッシュデータを試す
        const cached = await AsyncStorage.getItem('cached_products')
        if (cached) return JSON.parse(cached)
        throw error
      }
    },
    staleTime: 10 * 60 * 1000,
  })
}

React Queryが取得とインメモリキャッシュを処理する。AsyncStorageがオフラインのフォールバックを提供する。

モバイルアプリのオフラインモード構築
#

モバイルでオフライン対応は任意じゃない。ユーザーは地下鉄、エレベーター、飛行機、電波が不安定な場所でアプリを開く。ネットワークが切れるたびにアプリが真っ白な画面やスピナーを表示するなら、ユーザーは離れていく。

Zustand、React Query、AsyncStorageを組み合わせれば、重量級フレームワークに頼らずに堅実なオフラインアーキテクチャが作れる。

ステップ1:ネットワーク状態を検知する
#

まず、デバイスがオンラインかオフラインかを知る必要がある。@react-native-community/netinfo を使い、Zustandでステートを追跡して、アプリ全体がリアクションできるようにしよう。

import { create } from 'zustand'
import NetInfo from '@react-native-community/netinfo'

interface NetworkState {
  isOnline: boolean
  setOnline: (online: boolean) => void
}

const useNetworkStore = create<NetworkState>((set) => ({
  isOnline: true,
  setOnline: (online) => set({ isOnline: online }),
}))

// アプリ起動時に一度だけ購読する
NetInfo.addEventListener((state) => {
  useNetworkStore.getState().setOnline(state.isConnected ?? false)
})

どのコンポーネントでも useNetworkStore((s) => s.isOnline) をチェックして、オフラインバナーの表示、ボタンの無効化、「オンラインに戻ったら変更が同期されます」というメッセージの表示ができる。

ステップ2:React Queryをオフライン用に設定する
#

React Queryは networkMode を通じたオフラインサポートが組み込まれている。これはデバイスがオフラインのときにクエリやミューテーションがどう振る舞うかを制御する。

import { QueryClient } from '@tanstack/react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      networkMode: 'offlineFirst',
      // キャッシュデータを即座に返し、オンライン時にバックグラウンドで再取得
      staleTime: 5 * 60 * 1000,
      gcTime: 24 * 60 * 60 * 1000, // キャッシュを24時間保持
      retry: (failureCount, error) => {
        // オフラインならリトライしない - また失敗するだけ
        if (!useNetworkStore.getState().isOnline) return false
        return failureCount < 3
      },
    },
    mutations: {
      networkMode: 'offlineFirst',
    },
  },
})

3つのネットワークモード:

ModeBehavior
online (default)Queries only fire when online. Pauses when offline.
alwaysQueries fire regardless of network. Your queryFn handles failures.
offlineFirstQueries fire once (for cached data), then pause until online to refetch.

ほとんどのモバイルアプリには offlineFirst が正解だ。ユーザーはキャッシュデータを即座に見れて、ネットワークが利用可能になったら新鮮なデータが読み込まれる。

ステップ3:React Queryのキャッシュを永続化する
#

デフォルトでは、React Queryのキャッシュはメモリ上にある。アプリ再起動 = キャッシュ空っぽ = ローディングスピナーだらけ。オフラインモードでは、再起動後もキャッシュが残るようにAsyncStorageに永続化する必要がある。

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: 24 * 60 * 60 * 1000, // 24時間 - maxAge以上でなければならない
    },
  },
})

const asyncStoragePersister = createAsyncStoragePersister({
  storage: AsyncStorage,
  key: 'react-query-cache',
})

// Appコンポーネントで
function App() {
  return (
    <PersistQueryClientProvider
      client={queryClient}
      persistOptions={{
        persister: asyncStoragePersister,
        maxAge: 24 * 60 * 60 * 1000, // 24時間より古いデータは復元しない
        dehydrateOptions: {
          shouldDehydrateQuery: (query) => {
            // 成功したクエリだけを永続化する
            return query.state.status === 'success'
          },
        },
      }}
    >
      <YourApp />
    </PersistQueryClientProvider>
  )
}

ユーザーがオフラインでアプリを開くと、空白画面ではなく最後に取得したデータがすぐ表示される。ネットワークが戻ったら、React Queryがバックグラウンドで再取得する。

ステップ4:オフライン中のミューテーションをキューに入れる
#

オフラインでキャッシュデータを読むのは簡単だ。書き込みの処理は難しい。ユーザーがオフライン中に投稿を作成したり、注文を送信したり、プロフィールを更新したりしたら、そのミューテーションをキューに入れて、ネットワークが戻ったときに再実行する必要がある。

React Queryは useMutation と楽観的更新のための onMutate でこれを処理する:

import { useMutation, useQueryClient } from '@tanstack/react-query'
import AsyncStorage from '@react-native-async-storage/async-storage'

interface Comment {
  id: string
  text: string
  postId: string
  createdAt: string
  pending?: boolean
}

function useAddComment(postId: string) {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async (text: string) => {
      const res = await fetch(`/api/posts/${postId}/comments`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ text }),
      })
      return res.json()
    },

    // 楽観的更新 - コメントを即座に表示する
    onMutate: async (text) => {
      await queryClient.cancelQueries({ queryKey: ['comments', postId] })

      const previous = queryClient.getQueryData<Comment[]>(['comments', postId])

      const optimisticComment: Comment = {
        id: `temp-${Date.now()}`,
        text,
        postId,
        createdAt: new Date().toISOString(),
        pending: true, // UIに「送信中...」のインジケーターを表示する
      }

      queryClient.setQueryData<Comment[]>(
        ['comments', postId],
        (old) => [...(old ?? []), optimisticComment]
      )

      return { previous }
    },

    // 失敗したらロールバック
    onError: (err, text, context) => {
      if (context?.previous) {
        queryClient.setQueryData(['comments', postId], context.previous)
      }
    },

    // サーバーから実際のデータを取得するために再取得する
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['comments', postId] })
    },
  })
}

ミューテーションに networkMode: 'offlineFirst' を設定しておくと、React Queryはネットワークが戻るまで mutationFn を一時停止する。楽観的更新がUI上でコメントを即座に表示し、デバイスが再接続したら実際のAPIコールが実行される。

より複雑なオフラインキュー(何十もの保留中の変更を順番に同期するなど)には、ミューテーションキューを永続化できる:

import { MutationCache } from '@tanstack/react-query'

// 保留中のミューテーションをAsyncStorageに保存する
const mutationCache = new MutationCache({
  onError: async (error, variables, context, mutation) => {
    // デバッグのために失敗したミューテーションを記録する
    const pending = JSON.parse(
      await AsyncStorage.getItem('pending_mutations') ?? '[]'
    )
    pending.push({
      key: mutation.options.mutationKey,
      variables,
      timestamp: Date.now(),
    })
    await AsyncStorage.setItem('pending_mutations', JSON.stringify(pending))
  },
})

ステップ5:オンラインに戻ったら同期する
#

ネットワークが戻ったら、ローカルの変更をサーバーと整合させる必要がある。React Queryはこのほとんどを自動で処理する - 一時停止していたミューテーションが再開し、古いクエリが再取得される。しかし、エッジケースも処理すべきだ。

import NetInfo from '@react-native-community/netinfo'
import { onlineManager, focusManager } from '@tanstack/react-query'
import { AppState } from 'react-native'

// React Queryにネットワーク状態の変化を伝える
onlineManager.setEventListener((setOnline) => {
  return NetInfo.addEventListener((state) => {
    setOnline(!!state.isConnected)
  })
})

// アプリがフォアグラウンドに戻ったら再取得する
focusManager.setEventListener((setFocused) => {
  const subscription = AppState.addEventListener('change', (status) => {
    setFocused(status === 'active')
  })
  return () => subscription.remove()
})

ユーザーがアプリを1時間バックグラウンドにして戻ってきたら、focusManager が再取得をトリガーして新鮮なデータが表示される。トンネルから出て電波が復活したら、onlineManager が一時停止していたミューテーションを再開する。

ステップ6:UIにオフライン状態を表示する
#

ネットワークエラーを黙って飲み込むな。ユーザーがオフラインであること、変更が保留中であることを伝えよう。

import { useNetworkStore } from './stores/network'

function OfflineBanner() {
  const isOnline = useNetworkStore((s) => s.isOnline)

  if (isOnline) return null

  return (
    <View style={styles.banner}>
      <Text>You're offline. Changes will sync when you reconnect.</Text>
    </View>
  )
}

function CommentItem({ comment }: { comment: Comment }) {
  return (
    <View style={[styles.comment, comment.pending && styles.pending]}>
      <Text>{comment.text}</Text>
      {comment.pending && (
        <Text style={styles.pendingLabel}>Sending...</Text>
      )}
    </View>
  )
}

オフラインアーキテクチャのまとめ
#

オフラインモードで3つのツールがどう組み合わさるか:

┌─────────────────────────────────────────────────┐
│                   Components                     │
│  useQuery() for reads    useMutation() for writes│
└──────────┬──────────────────────┬────────────────┘
           │                      │
     ┌─────▼──────┐        ┌─────▼──────┐
     │ React Query │        │ React Query │
     │   Cache     │        │  Mutation   │
     │ (in-memory) │        │   Queue     │
     └─────┬──────┘        └─────┬──────┘
           │                      │
     ┌─────▼──────────────────────▼──────┐
     │     AsyncStorage Persister         │
     │  (survives app restart)            │
     └─────┬──────────────────────┬──────┘
           │                      │
     ┌─────▼──────┐        ┌─────▼──────┐
     │   Zustand   │        │   Network  │
     │ (isOnline,  │◄───────│   NetInfo  │
     │  UI state)  │        │            │
     └────────────┘        └────────────┘
LayerToolRole
Network detectionZustand + NetInfoTrack online/offline, drive UI banners
Data fetchingReact QueryFetch when online, serve cache when offline
Cache persistenceReact Query + AsyncStorageRestore cache on app restart
Offline writesReact Query mutationsQueue mutations, replay on reconnect
Optimistic UIReact Query onMutateShow changes immediately, roll back on failure
App focus syncReact Query focusManagerRefetch stale data when app returns to foreground

もっと重いものが必要なとき
#

これはサーバーが真のソースであり、オフラインが一時的な場合にうまく機能する。もしアプリが本格的なオフラインファーストで競合解決が必要なら - 例えば2台のデバイスが同じドキュメントをオフラインで編集するノートアプリのような場合 - 専用のシンクエンジンが必要だ:

  • WatermelonDB: React Native向けに作られ、内部的にSQLiteを使用し、競合解決のためのシンクプロトコルを持つ
  • Realm (Atlas Device Sync): MongoDB Atlasによる自動競合解決を備えた完全なオフラインファーストデータベース
  • PowerSync: 既存のPostgresバックエンドと連携するSQLiteベースのシンクレイヤー
  • Expo SQLite + カスタムシンク: 完全なコントロールが必要なら自分で作る

Zustand + React Query + AsyncStorageはモバイルアプリの約80%をカバーする。残りの20% - 共同編集、マルチデバイス同期、オフライン主体のワークフロー - には専用のシンクデータベースが必要だ。

判断フレームワーク
#

あるステートがどこに属するかわからないとき、こう自問しよう:

QuestionYes → Use
Does it come from a server/API?React Query
Is it client-only UI state shared across components?Zustand
Does it need to survive an app restart?AsyncStorage (+ optionally Zustand persist)
Is it sensitive (tokens, passwords)?expo-secure-store / react-native-keychain
Is it large structured data that needs querying?SQLite / WatermelonDB / Realm
Is it a simple form input used by one component?useState

よくあるパターン
#

StateToolWhy
API response dataReact QueryCaching, dedup, refetch, loading states
Selected tab / active filterZustandClient-only, multiple components care
Auth tokenAsyncStorage + ZustandPersists across restarts, fast in-memory access
Theme preferenceZustand with persist middlewareClient state that should survive restarts
Shopping cartZustand with persist middlewareComplex client logic, should survive restarts
Form inputuseStateSingle component, no need to share
User profile from APIReact QueryServer state, might be stale
Onboarding completed flagAsyncStorageJust a boolean that persists
Offline cached feedReact Query + AsyncStorageFetch from server, fall back to cache

プラットフォーム固有の考慮事項
#

React Native (Android + iOS)
#

3つすべてをインストールする:

npm install zustand @tanstack/react-query @react-native-async-storage/async-storage

機密データにはセキュアストレージを追加する:

npx expo install expo-secure-store
# or
npm install react-native-keychain

Expo
#

AsyncStorageはExpoでそのまま動く。ネイティブリンクは不要だ。

npx expo install @react-native-async-storage/async-storage

Web専用のReactアプリ
#

WebではAsyncStorageは不要だ。localStorageIndexedDB を直接使おう。

npm install zustand @tanstack/react-query
# Optional for IndexedDB
npm install idb-keyval

Zustandの永続化ミドルウェアはネイティブで localStorage をサポートしている:

persist(storeConfig, {
  name: 'my-store',
  // localStorage is the default on web  - no extra config needed
})

避けるべきアンチパターン
#

APIデータをZustandに入れる。 Zustandのアクションで setUsers(apiResponse.users) と書いているなら、やめよう。React Queryを使え。キャッシュの無効化を自分で再発明することになるが、うまくいかない。

React Queryをクライアントステートに使う。 queryFn がネットワークリクエストをしないなら、ツールの選択を間違えている。React Queryはサーバーステート用だ。

AsyncStorageに大きなデータを保存する。 AsyncStorageはキーバリューストアだ。10,000件のアイテムの配列をシリアライズしているなら、データベースを使え。

トークンを暗号化しない。 AsyncStorageはセキュアストレージではない。認証トークン、APIキー、ユーザーの認証情報は expo-secure-store やプラットフォームのキーチェーンに入れるべきだ。

永続化ミドルウェアを使わない。 マウント時にAsyncStorageから手動で読み込み、ステート変更のたびに手動で書き込んでいるなら、Zustandの永続化ミドルウェアを使おう。ハイドレーション、シリアライゼーション、ストレージ同期を全部やってくれる。


サーバーデータはReact Queryに。クライアントステートはZustandに。永続データはAsyncStorageに。機密データはセキュアストレージに。

自分が見てきた中で最悪のアーキテクチャは、全部を一つの巨大なストアに流し込むやつだ。最良のものは、サーバーステート、クライアントステート、永続化が必要なものの間に明確な線引きがある。早い段階でそれを見極めれば、あとは全部楽になる。