📊 状态管理

Zustand / Jotai / TanStack Query / Redux — 2024-2026 年前端状态管理的核心洞察:不是选「哪个最强」,而是分清「客户端状态」和「服务端状态」,然后各司其职。

01 核心认知:状态的两副面孔

前端状态管理最大的坑,是把两种本质不同的状态混在一起管:

🖥️ 客户端状态 (Client State)

只存在于浏览器中的状态:UI 开关、表单输入、主题偏好、侧边栏展开/收起。

特点:同步 你拥有完全控制权 不会"过时"

工具:Zustand、Jotai、Redux、Context

☁️ 服务端状态 (Server State)

从 API 获取的数据:用户列表、订单详情、搜索结果。你只是缓存了一个远程的副本。

特点:异步 可能随时过时 共享所有权

工具:TanStack Query、SWR

💡 关键洞察:把服务端状态当客户端状态管,是最常见的错误。用 Redux 存 API 数据,等于你自己手写了一个缓存层——而这正是 TanStack Query 已经帮你做好的事。选状态管理方案的第一步,永远是先问:这个状态属于哪一边?

02 Zustand — 极简全局状态 🔥 推荐

是什么

Zustand(德语「状态」)是一个极简的 React 状态管理库。核心 API 只有一个 create 函数,没有 Provider、没有 Reducer、没有 Action Type 字符串——就是创建一个 hook,然后调用它。

何时用

核心用法

// store/cartStore.ts
import { create } from 'zustand'
import { persist, devtools } from 'zustand/middleware'

interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
}

interface CartStore {
  items: CartItem[]
  addItem: (item: Omit<CartItem, 'quantity'>) => void
  removeItem: (id: string) => void
  clearCart: () => void
  total: () => number
}

export const useCartStore = create<CartStore>()(
  devtools(
    persist(
      (set, get) => ({
        items: [],

        addItem: (item) =>
          set(
            (state) => {
              const existing = state.items.find((i) => i.id === item.id)
              if (existing) {
                return {
                  items: state.items.map((i) =>
                    i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
                  ),
                }
              }
              return { items: [...state.items, { ...item, quantity: 1 }] }
            },
            false,
            { type: 'cart/addItem' }
          ),

        removeItem: (id) =>
          set(
            (state) => ({ items: state.items.filter((i) => i.id !== id) }),
            false,
            { type: 'cart/removeItem' }
          ),

        clearCart: () => set({ items: [] }, false, { type: 'cart/clear' }),

        total: () =>
          get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
      }),
      { name: 'cart-storage' }
    ),
    { name: 'CartStore' }
  )
)

选择器与渲染控制

// ❌ 每次任何字段变化都重渲染
const state = useCartStore()

// ✅ 只在 items 变化时重渲染
const items = useCartStore((s) => s.items)

// ✅ 派生计算也只在结果变化时重渲染
const isEmpty = useCartStore((s) => s.items.length === 0)

// ✅ 用 shallow 比较对象
import { shallow } from 'zustand/shallow'
const { addItem, removeItem } = useCartStore(
  (s) => ({ addItem: s.addItem, removeItem: s.removeItem }),
  shallow
)

Store 切片 (Slices) 模式

// store/slices/userSlice.ts
import { StateCreator } from 'zustand'

export interface UserSlice {
  user: { id: string; name: string } | null
  login: (id: string, name: string) => void
  logout: () => void
}

export const createUserSlice: StateCreator<AppStore, [], [], UserSlice> = (set) => ({
  user: null,
  login: (id, name) => set({ user: { id, name } }),
  logout: () => set({ user: null }),
})

// store/index.ts — 合并切片
type AppStore = UserSlice & UISlice

export const useAppStore = create<AppStore>()((...a) => ({
  ...createUserSlice(...a),
  ...createUISlice(...a),
}))

React 外部使用

// 在非 React 代码中直接访问 store
const items = useCartStore.getState().items
useCartStore.setState({ items: [] })
const unsub = useCartStore.subscribe((state) => {
  console.log('Cart changed:', state.items)
})
💡 与 Agent 架构的联系:Zustand 的 subscribe + getState 模式与 Agent 研究中的事件驱动架构异曲同工——OpenHands 用 Event Stream 驱动 UI 更新,Zustand 用 subscribe 响应状态变化。核心思想都是:状态的消费者不需要知道谁改了状态,只需要响应变化

优劣对比

✅ 优势

  • API 极简——create 一个函数搞定
  • 无 Provider 包裹,不会嵌套地狱
  • 选择器机制天然避免不必要渲染
  • 可在 React 外使用(测试、工具函数)
  • 中间件管道灵活组合
  • Bundle 仅 ~1.1KB gzipped

❌ 劣势

  • 没有官方 Time Travel 调试
  • Slice 模式需要手动组合
  • 异步操作无统一约定
  • 大型项目缺少强约束,容易写出不一致的 store

03 Jotai — 原子化状态 ✨ 独特范式

是什么

Jotai(日语「状态」)采用自底向上的原子化 (Atomic) 范式。你定义的是最小的状态单元(atom),组件按需组合使用。与 Zustand/Redux 的「Store」范式截然相反——没有集中式 Store,状态是分散的、按需组合的。

何时用

核心用法

// atoms/userAtoms.ts
import { atom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'

// 基础 atom — 原始状态
export const userIdAtom = atomWithStorage<string | null>('userId', null)

// 派生 atom — 只读计算
export const isLoggedInAtom = atom((get) => get(userIdAtom) !== null)

// 可写派生 atom — 读 + 写
export const loginAtom = atom(
  (get) => get(userIdAtom),
  (_get, set, newId: string) => set(userIdAtom, newId)
)

// 异步 atom — 直接在 atom 中处理 API 调用
export const userProfileAtom = atom(async (get) => {
  const id = get(userIdAtom)
  if (!id) return null
  const res = await fetch(`/api/users/${id}`)
  return res.json()
})

组件中使用

import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { userIdAtom, userProfileAtom, loginAtom } from '@/atoms/userAtoms'

export function UserAvatar() {
  const [userId, setUserId] = useAtom(userIdAtom)
  const profile = useAtomValue(userProfileAtom)
  const login = useSetAtom(loginAtom)

  if (!profile) return <button onClick={() => login('123')}>登录</button>
  return <img src={profile.avatar} alt={profile.name} />
}

关键特性深度解析

1. 依赖图自动追踪

Jotai 内部维护依赖图。当 userIdAtom 变化时,只有依赖它的 isLoggedInAtomuserProfileAtom 会被重新计算,其他 atom 的消费者不会重渲染。

userIdAtom isLoggedInAtom 组件 A (重渲染)
userIdAtom userProfileAtom (async) 组件 B (重渲染)
themeAtom 组件 C (不受影响 ✅)

2. 丰富的工具 Atom

import { atomWithStorage } from 'jotai/utils'    // localStorage 持久化
import { atomWithReducer } from 'jotai/utils'     // reducer 模式
import { atomWithReset, RESET } from 'jotai/utils' // 可重置 atom
import { selectAtom } from 'jotai/utils'           // 选择器派生
import { focusAtom } from 'jotai-optics'           // 聚焦子字段
import { atomWithCache } from 'jotai-cache'        // HTTP 缓存

// atomWithStorage — 自动持久化
const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light')

// atomWithReducer — 经典 reducer 模式
const counterAtom = atomWithReducer(0, (state, action: 'inc' | 'dec') => {
  switch (action) {
    case 'inc': return state + 1
    case 'dec': return state - 1
  }
})

// selectAtom — 选择性派生(只关注变化的部分)
const userNameAtom = selectAtom(userProfileAtom, (profile) => profile?.name ?? '')

3. Scope 隔离

// Provider + scope 隔离——同一页面多实例
import { Provider } from 'jotai'

function App() {
  return (
    <div>
      <Provider scope="chart1"><Chart /></Provider>
      <Provider scope="chart2"><Chart /></Provider>
    </div>
  )
}

优劣对比

✅ 优势

  • 渲染优化自动完成——依赖追踪
  • 无需 Provider(除非需要 scope 隔离)
  • atom 可组合——派生、异步、工具 atom
  • Bundle ~2.5KB gzipped
  • 与 React Suspense 深度集成
  • 适合组件库——每个组件自带状态

❌ 劣势

  • 调试体验弱——atom 分散难追踪
  • 大量 atom 时文件管理是挑战
  • 原子化范式有学习曲线
  • 与 DevTools 集成不如 Redux
  • 不适合需要全局快照的场景

04 TanStack Query — 服务端状态之王 🔥 必装

是什么

TanStack Query(前身 React Query)不是传统意义上的状态管理库。它专注解决服务端状态的获取、缓存、同步和更新。如果你用 Zustand/Redux 存 API 数据——你大概率应该换成 TanStack Query。

何时用

核心用法

// lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query'

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,     // 5 分钟内不重新请求
      gcTime: 30 * 60 * 1000,       // 30 分钟后垃圾回收
      retry: 2,                       // 失败重试 2 次
      refetchOnWindowFocus: false,    // 关闭窗口聚焦时自动刷新
    },
  },
})

// app/layout.tsx
import { QueryClientProvider } from '@tanstack/react-query'

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

useQuery — 查询数据

// hooks/useProjects.ts
import { useQuery } from '@tanstack/react-query'

interface Project {
  id: string
  name: string
  status: 'active' | 'archived'
}

async function fetchProjects(): Promise<Project[]> {
  const res = await fetch('/api/projects')
  if (!res.ok) throw new Error('Failed to fetch projects')
  return res.json()
}

export function useProjects(status?: Project['status']) {
  return useQuery({
    queryKey: ['projects', { status }],   // 缓存 key——参数变化 = 新请求
    queryFn: fetchProjects,
    select: (data) =>                      // 数据转换(不触发额外渲染)
      status ? data.filter((p) => p.status === status) : data,
    placeholderData: (prev) => prev,       // 切换参数时保留旧数据
  })
}

// 组件中使用
function ProjectList() {
  const { data, isPending, error, isFetching } = useProjects('active')

  if (isPending) return <Skeleton />
  if (error) return <ErrorBanner error={error} />

  return (
    <div>
      {isFetching && <Spinner />}   {/* 后台刷新指示 */}
      {data!.map((p) => <ProjectCard key={p.id} project={p} />)}
    </div>
  )
}

useMutation — 修改数据 + 乐观更新

// hooks/useUpdateProject.ts
import { useMutation, useQueryClient } from '@tanstack/react-query'

async function updateProject(id: string, data: Partial<Project>) {
  const res = await fetch(`/api/projects/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  })
  if (!res.ok) throw new Error('Update failed')
  return res.json()
}

export function useUpdateProject() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: Partial<Project> }) =>
      updateProject(id, data),

    // 🎯 乐观更新——立即更新 UI,不等 API 响应
    onMutate: async ({ id, data }) => {
      // 取消正在进行的查询,避免覆盖乐观更新
      await queryClient.cancelQueries({ queryKey: ['projects'] })

      // 保存当前快照(用于回滚)
      const previous = queryClient.getQueryData<Project[]>(['projects'])

      // 乐观更新缓存
      queryClient.setQueryData<Project[]>(['projects'], (old) =>
        old?.map((p) => (p.id === id ? { ...p, ...data } : p))
      )

      return { previous }
    },

    // 失败时回滚
    onError: (err, variables, context) => {
      queryClient.setQueryData(['projects'], context?.previous)
      toast.error('更新失败: ' + err.message)
    },

    // 无论成功失败,都重新获取最新数据
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['projects'] })
    },
  })
}

// 组件中使用
function ProjectEditor({ project }: { project: Project }) {
  const update = useUpdateProject()

  return (
    <input
      defaultValue={project.name}
      onBlur={(e) => update.mutate({ id: project.id, data: { name: e.target.value } })}
    />
  )
}

useInfiniteQuery — 无限滚动

function ProjectFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['projects', 'feed'],
    queryFn: ({ pageParam = 0 }) =>
      fetch(`/api/projects?cursor=${pageParam}`).then((r) => r.json()),
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    initialPageParam: 0,
  })

  return (
    <div>
      {data?.pages.flatMap((page) =>
        page.items.map((p) => <ProjectCard key={p.id} project={p} />)
      )}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? '加载中...' : '加载更多'}
        </button>
      )}
    </div>
  )
}

缓存策略核心概念

概念说明典型值
staleTime数据「新鲜」时长——此时间内不会重新请求0 ~ 5min
gcTime未被使用的缓存保留时长(之前叫 cacheTime5 ~ 30min
refetchOnWindowFocus窗口聚焦时是否重新请求生产环境建议 false
refetchOnReconnect网络重连时是否重新请求默认 true
placeholderData新请求加载中显示的占位数据keepPreviousData
💡 staleTime 是你最重要的配置:默认 staleTime: 0 意味着每次组件挂载都重新请求。大多数 SaaS 数据 5 分钟内不会变——设成 5 * 60 * 1000 可以大幅减少不必要的请求。高频变化的数据(如通知数)可以设 30 * 1000

优劣对比

✅ 优势

  • 开箱即用的缓存、去重、后台刷新
  • 乐观更新让 UI 感觉即时
  • 内置 loading/error/stale 状态
  • 无限滚动、分页开箱支持
  • 框架无关(React/Vue/Svelte/Solid)
  • DevTools 可视化缓存状态

❌ 劣势

  • 不管理客户端状态(需配合 Zustand/Jotai)
  • 缓存 key 设计需要经验
  • staleTime/gcTime 概念有学习曲线
  • SSR 配置稍复杂

05 Redux Toolkit — 企业级状态管理 🏛️ 经典

是什么

Redux 是最老牌的 React 状态管理库。Redux Toolkit (RTK) 是官方推荐写法,大幅减少了样板代码。核心思想:单一 Store、纯函数 Reducer、Action 驱动的状态变更。

何时用

核心用法 (RTK)

// store/projectsSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from './index'

export const fetchProjects = createAsyncThunk(
  'projects/fetchAll',
  async (status?: string) => {
    const res = await fetch(`/api/projects${status ? `?status=${status}` : ''}`)
    return res.json()
  }
)

const projectsSlice = createSlice({
  name: 'projects',
  initialState: {
    items: [] as Project[],
    loading: false,
    error: null as string | null,
  },
  reducers: {
    addProject: (state, action: PayloadAction<Project>) => {
      state.items.push(action.payload)
    },
    removeProject: (state, action: PayloadAction<string>) => {
      state.items = state.items.filter((p) => p.id !== action.payload)
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchProjects.pending, (state) => { state.loading = true })
      .addCase(fetchProjects.fulfilled, (state, action) => {
        state.loading = false
        state.items = action.payload
      })
      .addCase(fetchProjects.rejected, (state, action) => {
        state.loading = false
        state.error = action.error.message ?? 'Unknown error'
      })
  },
})

export const selectActiveProjects = (state: RootState) =>
  state.projects.items.filter((p) => p.status === 'active')

export const { addProject, removeProject } = projectsSlice.actions
export default projectsSlice.reducer
⚠️ 新项目的忠告:2024-2026 年,如果你新开一个 SaaS 项目,不推荐 Redux作为默认选择。Zustand + TanStack Query 的组合更轻量、更现代。Redux 的价值在于大团队统一规范和 DevTools 生态——这些在小团队里不是刚需。

优劣对比

✅ 优势

  • 最成熟的 DevTools 生态(Time Travel)
  • RTK 大幅减少样板代码
  • createAsyncThunk 统一异步模式
  • 中间件生态丰富
  • RTK Query 内置数据获取方案

❌ 劣势

  • 概念多(Store/Reducer/Action/Dispatch/Thunk)
  • Provider 必须包裹根组件
  • Bundle 较大 (~11KB RTK core)
  • 过度工程化风险高
  • Selector 性能需要 reselect 优化

06 横向对比:如何选择?

维度ZustandJotaiTanStack QueryRedux Toolkit
范式集中式 Store原子化 Atom缓存层Store + Reducer
适用状态客户端客户端服务端客户端
Bundle~1.1KB~2.5KB~13KB~11KB
Provider不需要可选需要需要
学习曲线极低
渲染优化手动选择器自动依赖追踪自动 queryKeyreselect
DevTools基础基础优秀最佳
异步手动async atom核心能力asyncThunk
适合规模小-大小-中任何中-大

07 推荐组合策略

🏆 2024-2026 最佳实践:Zustand + TanStack Query

这是目前最主流、最简洁的组合:

🎨 替代组合:Jotai + TanStack Query

偏好原子化范式?Jotai 替换 Zustand:

08 常见陷阱与反模式

🚫 陷阱 1:用 Zustand/Redux 存 API 数据
手写 loading/error/data 逻辑,手动管理缓存失效——这些 TanStack Query 一行搞定。
🚫 陷阱 2:Zustand 选择器返回新对象
useStore((s) => ({ a: s.a, b: s.b })) 每次返回新引用 → 无限重渲染。
修复:useStore((s) => ({ a: s.a, b: s.b }), shallow)
🚫 陷阱 3:TanStack Query staleTime 默认 0
默认每次组件挂载都重新请求。设合理的 staleTime 是性能优化第一步。
⚠️ 陷阱 4:Jotai atom 写在组件内
const myAtom = atom(0) 在组件函数体内 → 每次渲染创建新 atom → 状态丢失。atom 必须在模块顶层。
⚠️ 陷阱 5:过度全局化
组件内部的 useState 就够了——只有跨组件共享的状态才需要全局管理。

09 替代方案一览

工具定位何时考虑
SWRTanStack Query 轻量替代只需基础数据获取
ValtioProxy-based 响应式喜欢 mutable 写法
MobXObservable 响应式已有代码库或偏好 OOP
RecoilFacebook 原子化已废弃,不推荐
XState有限状态机复杂多步流程
React ContextReact 内置极简场景
💡 XState 值得一提:复杂多步流程(支付、审批),XState 的有限状态机比 if/else 更可靠。它和 Zustand 不是竞争——XState 管「流程状态」,Zustand 管「应用状态」。在 Agent 主循环中,状态机同样是核心模式。

10 决策树:5 秒选方案

你的状态是什么?

API 数据? TanStack Query ✅

UI 状态(全局)? Zustand ✅

UI 状态(组件内)? useState ✅

复杂派生状态图? Jotai ✨

多步流程/状态机? XState ✨

大团队 + 已有 Redux? Redux Toolkit 🏛️

11 SaaS 实战模板

// 📁 典型 SaaS 状态架构
//
// src/lib/queryClient.ts  — TanStack Query 配置
// src/lib/api.ts          — API 调用封装
//
// src/stores/authStore.ts — Zustand: 认证状态
// src/stores/uiStore.ts   — Zustand: UI 状态
// src/stores/filterStore.ts — Zustand: 筛选条件
//
// src/hooks/useProjects.ts  — TanStack Query: 项目列表
// src/hooks/useCreateProject.ts — TanStack Query: 创建项目
//
// 🔗 Zustand + TanStack Query 联动:
export const useFilterStore = create((set) => ({
  status: 'all',
  sortBy: 'updatedAt',
  setStatus: (status) => set({ status }),
  setSortBy: (sortBy) => set({ sortBy }),
}))

export function useFilteredProjects() {
  const status = useFilterStore((s) => s.status)
  const sortBy = useFilterStore((s) => s.sortBy)
  return useQuery({
    queryKey: ['projects', { status, sortBy }],
    queryFn: () => api.projects.list({ status, sortBy }),
  })
}
💡 架构精髓:Zustand 存「用户想要什么筛选条件」,TanStack Query 根据条件获取数据。通过 queryKey 天然联动——条件变了 → queryKey 变了 → 自动重新请求。不需要手动订阅、不需要 useEffect。