前端状态管理最大的坑,是把两种本质不同的状态混在一起管:
只存在于浏览器中的状态:UI 开关、表单输入、主题偏好、侧边栏展开/收起。
特点:同步 你拥有完全控制权 不会"过时"
工具:Zustand、Jotai、Redux、Context
从 API 获取的数据:用户列表、订单详情、搜索结果。你只是缓存了一个远程的副本。
特点:异步 可能随时过时 共享所有权
工具:TanStack Query、SWR
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/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 代码中直接访问 store
const items = useCartStore.getState().items
useCartStore.setState({ items: [] })
const unsub = useCartStore.subscribe((state) => {
console.log('Cart changed:', state.items)
})
subscribe + getState 模式与 Agent 研究中的事件驱动架构异曲同工——OpenHands 用 Event Stream 驱动 UI 更新,Zustand 用 subscribe 响应状态变化。核心思想都是:状态的消费者不需要知道谁改了状态,只需要响应变化。
create 一个函数搞定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} />
}
Jotai 内部维护依赖图。当 userIdAtom 变化时,只有依赖它的 isLoggedInAtom 和 userProfileAtom 会被重新计算,其他 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 ?? '')
// Provider + scope 隔离——同一页面多实例
import { Provider } from 'jotai'
function App() {
return (
<div>
<Provider scope="chart1"><Chart /></Provider>
<Provider scope="chart2"><Chart /></Provider>
</div>
)
}
TanStack Query(前身 React Query)不是传统意义上的状态管理库。它专注解决服务端状态的获取、缓存、同步和更新。如果你用 Zustand/Redux 存 API 数据——你大概率应该换成 TanStack Query。
useEffect + useState 的数据获取模式// 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>
)
}
// 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>
)
}
// 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 } })}
/>
)
}
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 | 未被使用的缓存保留时长(之前叫 cacheTime) | 5 ~ 30min |
refetchOnWindowFocus | 窗口聚焦时是否重新请求 | 生产环境建议 false |
refetchOnReconnect | 网络重连时是否重新请求 | 默认 true |
placeholderData | 新请求加载中显示的占位数据 | keepPreviousData |
staleTime: 0 意味着每次组件挂载都重新请求。大多数 SaaS 数据 5 分钟内不会变——设成 5 * 60 * 1000 可以大幅减少不必要的请求。高频变化的数据(如通知数)可以设 30 * 1000。
Redux 是最老牌的 React 状态管理库。Redux Toolkit (RTK) 是官方推荐写法,大幅减少了样板代码。核心思想:单一 Store、纯函数 Reducer、Action 驱动的状态变更。
// 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
| 维度 | Zustand | Jotai | TanStack Query | Redux Toolkit |
|---|---|---|---|---|
| 范式 | 集中式 Store | 原子化 Atom | 缓存层 | Store + Reducer |
| 适用状态 | 客户端 | 客户端 | 服务端 | 客户端 |
| Bundle | ~1.1KB | ~2.5KB | ~13KB | ~11KB |
| Provider | 不需要 | 可选 | 需要 | 需要 |
| 学习曲线 | 极低 | 中 | 中 | 高 |
| 渲染优化 | 手动选择器 | 自动依赖追踪 | 自动 queryKey | reselect |
| DevTools | 基础 | 基础 | 优秀 | 最佳 |
| 异步 | 手动 | async atom | 核心能力 | asyncThunk |
| 适合规模 | 小-大 | 小-中 | 任何 | 中-大 |
这是目前最主流、最简洁的组合:
偏好原子化范式?Jotai 替换 Zustand:
useStore((s) => ({ a: s.a, b: s.b })) 每次返回新引用 → 无限重渲染。useStore((s) => ({ a: s.a, b: s.b }), shallow)
const myAtom = atom(0) 在组件函数体内 → 每次渲染创建新 atom → 状态丢失。atom 必须在模块顶层。
useState 就够了——只有跨组件共享的状态才需要全局管理。
| 工具 | 定位 | 何时考虑 |
|---|---|---|
SWR | TanStack Query 轻量替代 | 只需基础数据获取 |
Valtio | Proxy-based 响应式 | 喜欢 mutable 写法 |
MobX | Observable 响应式 | 已有代码库或偏好 OOP |
Recoil | Facebook 原子化 | 已废弃,不推荐 |
XState | 有限状态机 | 复杂多步流程 |
React Context | React 内置 | 极简场景 |
// 📁 典型 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 }),
})
}