メインコンテンツへスキップ
  1. 記事一覧/

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

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

いま開発しているカーテン見積もりアプリには、絶対に譲れない要件がひとつあります。電波がなくても動くこと。見積もりは現場での採寸から始まるのですが、現場は新築の建物だったり地下だったりで、電波はあてになりません。この制約のおかげで、多くのReactプロジェクトが曖昧なままにしている「どの種類のステートをどこに置くか」という問題に、正面から向き合わざるを得ませんでした。

よくある失敗は、すべてをひとつのストアに突っ込むことです。APIキャッシュもユーザー設定もフォームの状態も認証トークンも、全部同じReduxストアの中。でもこれらは性質もライフサイクルも違うステートで、それぞれに合った道具があります。Zustand、React Query、AsyncStorageはそれぞれちょうどひとつの問題をうまく解決してくれるので、境界線さえ引けば、アーキテクチャはだいたい勝手に片付きます。

ステートは3種類ある
#

ライブラリの話をする前に、自分が何を管理しているのかをはっきりさせておきましょう。

クライアントステートは、アプリのランタイムにしか存在しないデータです。UIのトグル、選択中のタブ、フォーム入力、モーダルの開閉。上流に所有者はおらず、プロセスが終われば消えます。

サーバーステートは事情が違います。所有者はサーバーで、アプリが持っているのはローカルコピーにすぎません。ユーザープロフィール、商品一覧、通知、フィード。手元のコピーは古くなりますし、再取得が必要ですし、今この瞬間に別のクライアントが別のバージョンを見ているかもしれません。

永続ステートは、再起動をまたいで残す必要があるデータです。認証トークン、オンボーディング完了フラグ、キャッシュした設定、オフライン用データ。デバイスそのものに保存されます。

たいていのアプリには3つとも存在します。そして、たいていの混乱は、3つをひとつの道具で扱おうとするところから始まります。

Zustand:クライアントステート担当
#

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に入れないもの
#

まずAPIレスポンス。APIコールのたびにZustandストアへ書き込み、コンポーネントはクエリの代わりにストアを読む、というプロジェクトを見たことがあります。行き着く先は、キャッシュ無効化・ローディング状態・エラーハンドリング・再取得・ページネーションの手作り再実装です。それこそReact Queryが肩代わりしてくれる仕事なんですよね。

もうひとつは、再起動後も残したいデータ。Zustandのステートはメモリ上にあるので、アプリを閉じれば消えます。persistミドルウェアでAsyncStorageに橋渡しすることはできますが(後述します)、それは意識的にやる判断であって、癖でやることではありません。

React Query:サーバーステート担当
#

React Query(今はTanStack 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 Nativeではやるべきです(後半のオフラインの節で扱います)。それでも、真実の源は常にサーバーです。

AsyncStorage:再起動を生き延びるもの担当
#

AsyncStorageはReact Nativeのキーバリューストレージです。Webでの相当品は localStorage(同期)か IndexedDB(非同期で高機能)。考え方はどちらも同じで、デバイスに書き込むことでプロセスより長生きするデータ、ということです。

プラットフォームごとの中身
#

@react-native-async-storage/async-storage を使っていればAPIはひとつですが、中身はプラットフォームごとにかなり違います。

Androidでは RKStorage 経由のSQLiteで、アプリの内部ストレージディレクトリ内のデータベースに保存されます。高速で信頼でき、サンドボックス化されているので他のアプリからは触れません。ただし、下の表にあるデフォルトのサイズ上限には注意してください。

iOSでは、小さな値は NSUserDefaults、大きな値はシリアライズされたファイルに入ります。こちらもアプリのコンテナ内にサンドボックス化されています。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

私が入れているもの
#

認証トークンとセッションデータ、残しておきたいユーザー設定(言語、テーマ、通知設定)、オンボーディング完了フラグ、オフライン用のキャッシュデータ。要するに「再起動を生き延びる必要のある小さなキーバリュー」で、カテゴリとしてはそれがすべてです。

APIはストレージとして考えられる限り素直な形をしています。

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') ?? '{}')

大きなデータセットには IndexedDB を使います。

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

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

向いていないもの
#

キーバリューストアであってデータベースではありません。リレーショナルデータ、数千件の配列、インデックスやクエリが必要なものは、SQLite(expo-sqlite 経由)、WatermelonDB、Realmの領分です。

セキュアストレージでもありません。root化・脱獄されたデバイスではAsyncStorageの中身は読めてしまいます。機密トークンは expo-secure-storereact-native-keychain に入れてください。

3つの組み合わせ方
#

実際のアプリでは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が自動的にユーザープロフィールを取得する
  }
}

AsyncStorageがトークンを再起動越しに保持し、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が管理し、persistミドルウェアが裏で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、という分担です。

オフラインモードを順番に組む
#

ここが私にとっての本題です。一般論としても、ユーザーは地下鉄やエレベーターや飛行機の中でアプリを開くものですが、私の場合はもっと切実で、見積もりは現場で書くものなのに、現場に電波があるとは限りません。地下でスピナーが回りっぱなしというのは、見積もりを1件落とすということです。

うれしいことに、この3つのライブラリだけで、重量級フレームワークに頼らず、かなりしっかりしたオフライン構成が組めます。

ステップ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:クエリキャッシュを永続化する
#

素の状態ではキャッシュはメモリだけなので、再起動すればそこら中スピナーだらけになります。オフラインモードでは、キャッシュを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の答えは、useMutationonMutate での楽観的更新です。

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' を設定しておくと、回線が戻るまで mutationFn は一時停止します。楽観的更新でコメントは即座に画面に出て、実際の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がほとんど自分でやってくれます。一時停止していたミューテーションは再開し、古くなったクエリは再取得されます。こちらの仕事は、React QueryのマネージャーをReact Nativeのイベントにつなぐことだけです。

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>
  )
}

全体像
#

┌─────────────────────────────────────────────────┐
│                   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で、モバイルアプリの8割くらいはカバーできます。残りの2割、つまり共同編集やマルチデバイス同期のようなオフライン中心のワークフローには、専用のシンクデータベースが必要です。

どこに置くか迷ったら
#

私は迷ったとき、この順番で自問しています。

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
#

AsyncStorageは飛ばして、localStorageIndexedDB を直接使ってください。

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

Zustandのpersistミドルウェアは、Webではデフォルトで localStorage を使ってくれます。

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

何度も見てきた失敗
#

APIデータをZustandに入れる。アクションの中に setUsers(apiResponse.users) があったら、一度手を止めてください。それはReact Queryの仕事で、その先に待っているのはキャッシュ無効化の下手な再発明です。

React Queryをクライアントステートに使う。queryFn がネットワークリクエストをしていないなら、道具を間違えています。

AsyncStorageをデータベース扱いする。1万件の配列をキーバリューストアにシリアライズし始めたら、素直にSQLiteなり本物のデータベースなりを使いましょう。

トークンを平文で置く。AsyncStorageはセキュアストレージではありません。認証トークン、APIキー、認証情報は expo-secure-store かプラットフォームのキーチェーンへ。

永続化を手作りする。マウント時にAsyncStorageを読んで、ステートが変わるたびに書き込んで……というコードを書いているなら、Zustandのpersistミドルウェアがハイドレーションもシリアライゼーションも含めて全部やってくれます。コードは減って、バグも減ります。


サーバーデータはReact Queryへ。クライアントステートはZustandへ。再起動を生き延びるべきものはAsyncStorageへ、機密データはセキュアストレージへ。

私が関わってきた中で最悪のアーキテクチャは、どれも巨大なストアひとつにすべてが流れ込むものでした。良いものには、サーバーステート・クライアントステート・永続化すべきものの間にはっきりした線が引いてあります。その線を早めに引いておくと、あとの全部が楽になります。