いま開発しているカーテン見積もりアプリには、絶対に譲れない要件がひとつあります。電波がなくても動くこと。見積もりは現場での採寸から始まるのですが、現場は新築の建物だったり地下だったりで、電波はあてになりません。この制約のおかげで、多くの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回だけ。ウィンドウがフォーカスを取り戻したり回線が復活したりすれば再取得してくれます。古いキャッシュは勝手にガベージコレクションされます。どのクエリも isLoading、isError、data などを返してくれるので、ローディングやエラーの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 を使えば十分です。
| Platform | Backend | Size Limit | Location |
|---|---|---|---|
| Android | SQLite (RKStorage) | ~6 MB default (configurable) | App internal storage |
| iOS | NSUserDefaults / files | No hard limit (keep values small) | App sandbox container |
| Web | localStorage | ~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-store か react-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つのモードはこうなっています。
| Mode | Behavior |
|---|---|
online (default) | Queries only fire when online. Pauses when offline. |
always | Queries fire regardless of network. Your queryFn handles failures. |
offlineFirst | Queries 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の答えは、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' を設定しておくと、回線が戻るまで 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) │ │ │
└────────────┘ └────────────┘| Layer | Tool | Role |
|---|---|---|
| Network detection | Zustand + NetInfo | Track online/offline, drive UI banners |
| Data fetching | React Query | Fetch when online, serve cache when offline |
| Cache persistence | React Query + AsyncStorage | Restore cache on app restart |
| Offline writes | React Query mutations | Queue mutations, replay on reconnect |
| Optimistic UI | React Query onMutate | Show changes immediately, roll back on failure |
| App focus sync | React Query focusManager | Refetch 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割、つまり共同編集やマルチデバイス同期のようなオフライン中心のワークフローには、専用のシンクデータベースが必要です。
どこに置くか迷ったら#
私は迷ったとき、この順番で自問しています。
| Question | Yes → 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 |
よくあるパターン#
| State | Tool | Why |
|---|---|---|
| API response data | React Query | Caching, dedup, refetch, loading states |
| Selected tab / active filter | Zustand | Client-only, multiple components care |
| Auth token | AsyncStorage + Zustand | Persists across restarts, fast in-memory access |
| Theme preference | Zustand with persist middleware | Client state that should survive restarts |
| Shopping cart | Zustand with persist middleware | Complex client logic, should survive restarts |
| Form input | useState | Single component, no need to share |
| User profile from API | React Query | Server state, might be stale |
| Onboarding completed flag | AsyncStorage | Just a boolean that persists |
| Offline cached feed | React Query + AsyncStorage | Fetch 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-keychainExpo#
AsyncStorageはExpoでそのまま動きます。ネイティブリンクは不要です。
npx expo install @react-native-async-storage/async-storageWeb専用のReact#
AsyncStorageは飛ばして、localStorage か IndexedDB を直接使ってください。
npm install zustand @tanstack/react-query
# Optional for IndexedDB
npm install idb-keyvalZustandの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へ、機密データはセキュアストレージへ。
私が関わってきた中で最悪のアーキテクチャは、どれも巨大なストアひとつにすべてが流れ込むものでした。良いものには、サーバーステート・クライアントステート・永続化すべきものの間にはっきりした線が引いてあります。その線を早めに引いておくと、あとの全部が楽になります。

