跳过正文
  1. 文章/

Zustand vs React Query vs AsyncStorage:状态该放在哪里

· loading · loading ·
仁才德
作者
仁才德
居住在韩国首尔的领导者和软件工程师
目录

我正在做的窗帘报价 App 有一条硬性要求:没信号也得能用。报价从上门量尺开始,而现场经常是毛坯房或者地下室,信号根本靠不住。正是这条约束,逼着我认真回答一个大多数 React 项目都含糊对付的问题:每种状态到底该放在哪里?

最常见的翻车方式,是把所有东西塞进同一个 store。API 缓存、用户偏好、表单状态、认证令牌,全搅在同一个 Redux 大泥球里。可这些状态的性质和生命周期各不相同,各有各的合适工具。Zustand、React Query、AsyncStorage 每个都恰好把一个问题解决得很好。边界画清楚了,架构基本就自己理顺了。

状态分三种
#

聊库之前,先弄清楚自己到底在管理什么。

客户端状态只存在于应用运行时:UI 开关、选中的标签页、表单输入、弹窗开没开。上游没有谁拥有它,进程一结束它就没了。

服务器状态不一样。它的主人是服务器,应用手里只有一份本地副本。用户资料、商品列表、通知、动态流。你的副本会过期,需要重新拉取,而且此刻别的客户端可能正看着另一个版本。

持久化状态是那些必须扛过重启的数据:认证令牌、引导完成标记、缓存的偏好、离线数据。它存在设备本身上。

大多数应用三种都有。大多数烂摊子,都是拿一个工具硬管三种状态造成的。

Zustand:管客户端状态
#

Zustand 是个没什么仪式感的小状态库。没有 provider,没有模板代码,没有一层套一层的 context。建个 store,组件里直接用,要学的基本就这些。

它适合的场景:多个组件共享同一份 UI 状态(侧边栏开关、当前筛选条件、选中的项目);不来自 API 的应用级状态(主题、语言、启动时读的功能开关);以及真正的客户端逻辑,比如购物车计算或多步表单向导。凡是需要同步、可预测地更新的,基本都归它。

一个基础的 store:

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 store,组件不查询接口,改从 store 里读。结局就是自己手搓缓存失效、加载状态、错误处理、重新拉取和分页,而这些恰恰是 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>
  )
}

不用自己写的那些东西
#

我坚持把服务器数据放这儿,就是因为白送的机制实在太多。五个组件请求同一个用户,网络请求只发一次。窗口重新聚焦或网络重连时自动重新拉取。过期缓存自动垃圾回收。每个查询都带着 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-10 MB。纯 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

三个怎么搭配着用
#

真实的应用里三个是一起上的。几个我常用的搭配。

认证流程
#

// 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,分工就这么简单。

离线模式,一步一步来
#

这部分才是我真正关心的。通常的说法是用户会在地铁、电梯、飞机上打开应用,这没错。但我的情况更直接:报价就是在现场写的,而现场有没有信号全看运气。地下室里转圈的加载动画,意味着丢掉一单报价。

好消息是,就靠这三个库,不用上重型框架也能搭出相当扎实的离线方案。

第一步:知道自己断网了
#

设备得把网络状态告诉应用,应用得有个地方存这个答案。事件由 @react-native-community/netinfo 提供,再用一个小小的 Zustand store 让全应用都能读到。

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) 来显示离线横幅、禁用提交按钮,或者提示“联网后会自动同步”。

第二步:把离线告诉 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',
    },
  },
})

三种模式:

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 就对了:缓存数据立刻出现,新数据等网络允许时再来。

第三步:持久化查询缓存
#

默认缓存只在内存里,一重启就满屏转圈。要做离线模式,得把缓存持久化到 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 就在后台悄悄重拉。

第四步:离线时把写操作排进队列
#

读是容易的那一半。有意思的是写:用户断网时发了评论、提交了订单、改了资料,这些变更得排队存好,等网络回来再重放。

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

第五步:网络回来后对齐状态
#

重连的活儿 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()
})

用户把应用切后台一小时再回来,focusManager 会触发重拉,让他们看到新数据;走出隧道信号恢复时,onlineManager 会把暂停的操作重新跑起来。

第六步:在 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

什么时候需要真正的同步引擎
#

上面这套的前提是:服务器是数据的真实来源,离线只是暂时状态。如果你要的是带冲突解决的真·离线优先,比如两台设备离线编辑同一篇笔记,这套就不够了,得上专门的同步引擎。WatermelonDB 专为 React Native 打造,底层是 SQLite,自带解决冲突的同步协议。Realm 配上 Atlas Device Sync,就是一个通过 MongoDB Atlas 自动解决冲突的完整离线优先数据库。PowerSync 是基于 SQLite 的同步层,能直接对接你现有的 Postgres 后端。要完全的控制权,也可以 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)
#

三个一起装:

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

敏感数据再加上安全存储:

npx expo install expo-secure-store
# 或者
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
# 可选,用于 IndexedDB
npm install idb-keyval

Zustand 的 persist 中间件在 Web 上默认就用 localStorage

persist(storeConfig, {
  name: 'my-store',
  // localStorage 在 Web 端是默认的 - 不需要额外配置
})

我反复见到的错误
#

把 API 数据塞进 Zustand。如果你的 action 里出现了 setUsers(apiResponse.users),先停一下。那是 React Query 的活,再往前走一步,就是把缓存失效重新发明一遍,而且发明得不会太好。

用 React Query 管客户端状态。queryFn 里没有网络请求?工具用错了。

把 AsyncStorage 当数据库。往键值存储里序列化一万条记录的数组,结局不会体面。用数据库。

令牌不加密。AsyncStorage 不是安全存储。认证令牌、API 密钥、用户凭证,请放 expo-secure-store 或平台的 keychain。

手搓持久化。如果你在挂载时手动读 AsyncStorage、每次状态一变就手动写回去,Zustand 的 persist 中间件早就把这些连同水合、序列化一起做好了,代码更少,坑也更少。


服务器数据走 React Query。客户端状态放 Zustand。要扛过重启的进 AsyncStorage,敏感的进安全存储。

我经手过的最糟的架构,无一例外都是所有东西流经一个巨型 store。好的架构里,服务器状态、客户端状态、需要持久化的东西之间有清清楚楚的线。这些线画得越早,后面的一切就越省事。