React 状态管理方案对比
React 状态管理方案对比
状态管理是 React 应用的核心问题之一,随着应用复杂度的增加,选择合适的状态管理方案变得至关重要。React 提供了从简单的 useState 到复杂的状态管理库等多种方案,开发者需要根据应用规模、团队熟悉度、性能需求等因素选择合适的方案。
什么是"状态"
在 React 里,状态(state)是指那些"会随时间变化、并且变化后需要重新渲染 UI"的数据。可以把 React 组件想象成一个纯函数:给它一份状态(输入),它就产出一份 UI(输出)。当状态变化时,React 重新调用组件函数,用新的输出去更新界面。理解这个"状态驱动 UI"的心智模型,是理解一切状态管理方案的前提。
状态并不是只有一种。真实项目里的状态大致可以分成四类,弄清它们的差异,才能选对工具:
| 状态类型 | 典型例子 | 生命周期 | 推荐工具 |
|----------|----------|----------|----------|
| 本地 UI 状态 | 输入框内容、开关、Tab 选中 | 随组件挂载/卸载 | useState / useReducer |
| 全局共享状态 | 登录用户、主题、语言 | 随应用生命周期 | Context / Zustand / Jotai |
| 服务器状态 | 列表数据、详情、分页 | 有缓存和过期概念 | React Query / SWR |
| URL 状态 | 搜索关键词、筛选、页码 | 随路由变化 | 路由参数 / 查询参数 |
一个常见的错误就是"把所有状态都塞进一个全局 store"。服务器状态(本质是远端数据的一份缓存)和 UI 状态(本质是内存里的临时变量)有完全不同的诉求,混在一起会让代码越来越难维护。
为什么状态管理如此重要
想象一个中后台系统:顶部有用户信息,侧边栏有菜单,主区域有表格,表格里有筛选、分页、批量操作,还有一堆弹窗。如果没有清晰的状态管理策略,你会遇到:
好的状态管理方案要解决的正是这几个问题:共享、单一数据源、可预测的更新、精确的重渲染控制、可调试性。
内置状态管理方案
useState - 组件内部状态:
useState 是 React 最基础的状态管理 Hook,适用于组件内部的状态管理。useState 返回状态值和更新函数,支持惰性初始化和函数式更新。useState 的优势在于简单直观,与 React 组件生命周期紧密集成。useState 适用于表单输入、UI 状态(如模态框显示隐藏)、组件内部的计数器等场景。对于复杂的状态逻辑,可以考虑使用 useReducer 替代。
useState 有两个容易被忽视但很重要的用法:
useContext - 跨组件状态共享:
useContext 提供了一种在组件树中共享数据的方式,避免了 props 层层传递的问题。useContext 与 createContext 配合使用,Provider 提供数据,Consumer 或 useContext 消费数据。useContext 适用于主题、用户信息、语言设置等全局配置数据。需要注意的是,Context 的值变化会导致所有消费者组件重新渲染,对于频繁更新的状态可能需要优化。
关键要记住:Context 是一个"传递"工具,不是"状态管理"工具。它解决的是"怎么把数据传下去",而不是"怎么高效更新数据"。用 Context 存放频繁变化的状态(比如鼠标位置、输入框每一次按键),会导致所有消费该 Context 的组件全部重渲染,是性能杀手。
useReducer - 复杂状态逻辑:
useReducer 是 useState 的替代方案,适用于复杂的状态逻辑。useReducer 接收一个 reducer 函数和初始状态,返回当前状态和 dispatch 函数。useReducer 的优势在于将状态更新逻辑集中管理,便于测试和调试。useReducer 适用于表单状态、多步骤流程、复杂的数据转换等场景。
一个判断标准:当你发现一个组件里有五六个 useState,而且它们之间还互相牵连(改一个要连带改另一个),就是时候换成 useReducer 了。reducer 是一个纯函数,接收旧状态和 action,返回新状态,天然可测试。
内置方案代码示例
// useState 基础用法
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}
// useState 惰性初始化:昂贵的初始计算只跑一次
function TodoList() {
// ❌ 每次渲染都会执行 heavyParse
// const [todos, setTodos] = useState(heavyParse(localStorage.getItem('todos')));
// ✅ 只在首次渲染执行
const [todos, setTodos] = useState(() =>
heavyParse(localStorage.getItem('todos'))
);
return <ul>{todos.map(t => <li key={t.id}>{t.text}</li>)}</ul>;
}
// useContext 跨组件状态共享
const ThemeContext = createContext('light');
function App() {
const [theme, setTheme] = useState('dark');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Layout />
</ThemeContext.Provider>
);
}
function Layout() {
return (
<div>
<Header />
<Main />
</div>
);
}
function Header() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<header className={`header-${theme}`}>
<button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</header>
);
}
// useReducer 复杂状态管理
const formReducer = (state, action) => {
switch (action.type) {
case 'SET_FIELD':
return { ...state, [action.field]: action.value };
case 'SET_ERROR':
return { ...state, errors: { ...state.errors, [action.field]: action.error } };
case 'RESET':
return action.initialState;
default:
return state;
}
};
function Form() {
const initialState = { username: '', email: '', errors: {} };
const [state, dispatch] = useReducer(formReducer, initialState);
const handleChange = (field) => (e) => {
dispatch({ type: 'SET_FIELD', field, value: e.target.value });
};
return (
<form>
<input
value={state.username}
onChange={handleChange('username')}
/>
{state.errors.username && <span>{state.errors.username}</span>}
</form>
);
}Context 性能优化:拆分与 memo
Context 最常见的坑就是"牵一发动全身"。下面演示如何通过拆分 Context 和配合 memo 来减少重渲染。
// ❌ 反例:把频繁变化的值和稳定的值放同一个 Context
const AppContext = createContext(null);
function BadProvider({ children }) {
const [user, setUser] = useState(null); // 很少变
const [mouse, setMouse] = useState({ x: 0, y: 0 }); // 每次移动都变
// mouse 变化会让所有读 user 的组件也一起重渲染
return (
<AppContext.Provider value={{ user, setUser, mouse, setMouse }}>
{children}
</AppContext.Provider>
);
}
// ✅ 正例:按更新频率拆分 Context
const UserContext = createContext(null);
const MouseContext = createContext(null);
function GoodProvider({ children }) {
const [user, setUser] = useState(null);
const [mouse, setMouse] = useState({ x: 0, y: 0 });
// 用 useMemo 稳定 value 引用,避免 Provider 重渲染时误触发
const userValue = useMemo(() => ({ user, setUser }), [user]);
const mouseValue = useMemo(() => ({ mouse, setMouse }), [mouse]);
return (
<UserContext.Provider value={userValue}>
<MouseContext.Provider value={mouseValue}>
{children}
</MouseContext.Provider>
</UserContext.Provider>
);
}
// 只读 user 的组件不会因为鼠标移动而重渲染
const Avatar = memo(function Avatar() {
const { user } = useContext(UserContext);
return <img src={user?.avatar} alt="" />;
});第三方状态管理库
Redux Toolkit - 企业级状态管理:
Redux Toolkit 是 Redux 的官方推荐工具集,简化了 Redux 的配置和使用。Redux Toolkit 提供了 createSlice(同时定义 reducer 和 actions)、createAsyncThunk(处理异步操作)、configureStore(配置 store)等工具。Redux Toolkit 内置 Immer,支持不可变数据更新。Redux Toolkit 适合大型应用、需要时间旅行调试、需要中间件扩展的场景。
// Redux Toolkit 完整示例:同步 + 异步
import { createSlice, configureStore, createAsyncThunk } from '@reduxjs/toolkit';
// 异步 thunk:处理数据请求
export const fetchTodos = createAsyncThunk('todos/fetch', async (userId) => {
const res = await fetch(`/api/todos?userId=${userId}`);
return res.json();
});
const todosSlice = createSlice({
name: 'todos',
initialState: { list: [], status: 'idle', error: null },
reducers: {
// 得益于 Immer,可以"看似直接修改",底层仍是不可变更新
addTodo: (state, action) => {
state.list.push({ id: Date.now(), text: action.payload, done: false });
},
toggleTodo: (state, action) => {
const todo = state.list.find(t => t.id === action.payload);
if (todo) todo.done = !todo.done;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => { state.status = 'loading'; })
.addCase(fetchTodos.fulfilled, (state, action) => {
state.status = 'succeeded';
state.list = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
});
},
});
export const { addTodo, toggleTodo } = todosSlice.actions;
const store = configureStore({
reducer: { todos: todosSlice.reducer },
});
// React 组件中使用 Redux
import { useSelector, useDispatch } from 'react-redux';
function TodoApp() {
const { list, status } = useSelector((state) => state.todos);
const dispatch = useDispatch();
useEffect(() => { dispatch(fetchTodos(1)); }, [dispatch]);
if (status === 'loading') return <div>Loading...</div>;
return (
<ul>
{list.map((todo) => (
<li key={todo.id} onClick={() => dispatch(toggleTodo(todo.id))}>
{todo.done ? '✓ ' : ''}{todo.text}
</li>
))}
</ul>
);
}Zustand - 轻量级状态管理:
Zustand 是一个极简的状态管理库,API 设计简洁,学习曲线平缓。Zustand 基于 Hooks,不需要 Provider 包裹,支持选择器优化渲染。Zustand 支持中间件(如 persist、devtools)、TypeScript 类型推断。Zustand 适合中小型应用、需要快速上手的团队。
// Zustand 基础 store
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));
function CounterWithZustand() {
const { count, increment, decrement, reset } = useStore();
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
<button onClick={reset}>Reset</button>
</div>
);
}
// Zustand 选择器优化:只订阅需要的字段
function OptimizedCounter() {
// 组件只在 count 变化时重渲染,其他字段变化不影响
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
</div>
);
}
// Zustand 中间件组合:持久化 + devtools
const useCartStore = create(
devtools(
persist(
(set, get) => ({
items: [],
addItem: (product) =>
set((state) => ({ items: [...state.items, product] })),
removeItem: (id) =>
set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
total: () => get().items.reduce((sum, i) => sum + i.price, 0),
}),
{ name: 'cart-storage' } // localStorage key
)
)
);Jotai - 原子化状态管理:
Jotai 采用原子化状态管理,状态可以按需订阅和更新,实现细粒度的渲染优化。Jotai 的 atom 可以派生出新的 atom,支持异步 atom。Jotai 的 API 非常简洁,适合需要精确控制渲染的场景。原子化的思路和 Recoil 类似:把全局状态拆成一个个最小单元(atom),组件用哪个就订阅哪个,天然避免了"整棵树重渲染"。
import { atom, useAtom } from 'jotai';
// 基础 atom
const countAtom = atom(0);
// 派生 atom:自动依赖 countAtom
const doubleCountAtom = atom((get) => get(countAtom) * 2);
// 可写派生 atom
const decrementAtom = atom(
(get) => get(countAtom),
(get, set) => set(countAtom, get(countAtom) - 1)
);
// 异步 atom:直接在 atom 里发请求
const userAtom = atom(async (get) => {
const id = get(countAtom);
const res = await fetch(`/api/users/${id}`);
return res.json();
});
function CounterWithJotai() {
const [count, setCount] = useAtom(countAtom);
const [doubleCount] = useAtom(doubleCountAtom);
return (
<div>
<p>Count: {count}</p>
<p>Double: {doubleCount}</p>
<button onClick={() => setCount((c) => c + 1)}>+</button>
</div>
);
}React Query - 服务器状态管理:
React Query 专门用于管理服务器状态,提供了数据获取、缓存、轮询、乐观更新等功能。React Query 自动管理请求状态和缓存,支持后台刷新、预取、并行请求等高级特性。React Query 大大简化了 React 中的数据请求逻辑,推荐用于所有需要从服务器获取数据的场景。
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function UserList() {
const queryClient = useQueryClient();
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then((res) => res.json()),
staleTime: 5 * 60 * 1000, // 5 分钟内数据视为新鲜,不重新请求
});
// 乐观更新:先更新 UI,再请求,失败则回滚
const mutation = useMutation({
mutationFn: (newUser) =>
fetch('/api/users', {
method: 'POST',
body: JSON.stringify(newUser),
}).then((r) => r.json()),
onMutate: async (newUser) => {
await queryClient.cancelQueries({ queryKey: ['users'] });
const previous = queryClient.getQueryData(['users']);
queryClient.setQueryData(['users'], (old) => [...old, newUser]);
return { previous };
},
onError: (err, newUser, context) => {
queryClient.setQueryData(['users'], context.previous); // 回滚
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<button onClick={() => mutation.mutate({ id: Date.now(), name: 'New User' })}>
Add User
</button>
</div>
);
}状态分层:真实项目里的组合拳
实际项目很少只用一种方案,更常见的是"分层组合":UI 状态用 useState,全局客户端状态用 Zustand,服务器状态用 React Query。下面是一个典型的组合示例。
function StateManagementExample() {
// 第一层:组件内部 UI 状态
const [isModalOpen, setIsModalOpen] = useState(false);
// 第二层:全局客户端状态(登录用户)
const user = useUserStore((state) => state.user);
// 第三层:服务器状态(根据用户拉取数据)
const { data: posts } = useQuery({
queryKey: ['posts', user.id],
queryFn: () => fetchPosts(user.id),
});
return (
<div>
<button onClick={() => setIsModalOpen(true)}>Open Modal</button>
{isModalOpen && <Modal onClose={() => setIsModalOpen(false)} />}
<PostList posts={posts} />
</div>
);
}状态提升:共享状态的第一步
在引入任何库之前,React 官方推荐的第一招是"状态提升(lifting state up)":当两个兄弟组件需要共享同一份状态时,把状态移到它们最近的公共父组件里,再通过 props 往下传。
// 温度换算:摄氏和华氏输入框共享同一个温度状态
function Calculator() {
const [temperature, setTemperature] = useState('');
const [scale, setScale] = useState('c');
const celsius = scale === 'f' ? toCelsius(temperature) : temperature;
const fahrenheit = scale === 'c' ? toFahrenheit(temperature) : temperature;
return (
<>
<TemperatureInput
scale="c"
value={celsius}
onChange={(v) => { setScale('c'); setTemperature(v); }}
/>
<TemperatureInput
scale="f"
value={fahrenheit}
onChange={(v) => { setScale('f'); setTemperature(v); }}
/>
</>
);
}只有当提升的层级太深、props 透传太痛苦时,才需要升级到 Context 或全局库。不要跳过这一步直接上重型方案。
受控组件 vs 非受控组件
表单场景里,"状态由谁管"衍生出两种模式:受控组件(值由 React state 驱动)和非受控组件(值由 DOM 自己维护,用 ref 读取)。
// 受控组件:每次输入都触发 setState,React 是唯一数据源
function ControlledInput() {
const [value, setValue] = useState('');
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
// 非受控组件:DOM 自己存值,提交时才用 ref 读一次
function UncontrolledInput() {
const inputRef = useRef(null);
const handleSubmit = () => console.log(inputRef.current.value);
return (
<>
<input ref={inputRef} defaultValue="" />
<button onClick={handleSubmit}>提交</button>
</>
);
}受控组件便于实时校验和联动,但每次按键都重渲染;非受控组件性能更好但难以实时响应。大型表单推荐用 react-hook-form,它内部大量使用非受控 + 订阅机制,兼顾性能与体验。
用表单库管理表单状态
手写表单状态(值、错误、touched、提交中)非常繁琐。react-hook-form 把这些收敛成一套 API,且默认走非受控模式,性能优异。
import { useForm } from 'react-hook-form';
function SignupForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm();
const onSubmit = async (data) => {
await api.signup(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: '邮箱必填', pattern: /^\S+@\S+$/ })} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password', { minLength: { value: 8, message: '至少 8 位' } })} />
{errors.password && <span>{errors.password.message}</span>}
<button disabled={isSubmitting}>注册</button>
</form>
);
}把 URL 当作状态源
搜索关键词、筛选条件、当前页码这类状态,最好放到 URL 的查询参数里,而不是内存状态。这样刷新不丢、可分享、可前进后退,天然与浏览器历史集成。
// Next.js App Router 中读写 URL 查询参数
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
function SearchBox() {
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
const q = params.get('q') ?? '';
const onSearch = (value) => {
const next = new URLSearchParams(params);
next.set('q', value);
router.push(`${pathname}?${next.toString()}`); // 状态存进 URL
};
return <input value={q} onChange={(e) => onSearch(e.target.value)} />;
}状态机思维:XState 简介
当状态之间有复杂的"允许/禁止"转换规则时(比如一个订单只能 待支付 → 已支付 → 已发货,不能跳步),用状态机建模比一堆布尔值更清晰、更不易出错。XState 是最流行的 JS 状态机库。
import { createMachine } from 'xstate';
const orderMachine = createMachine({
id: 'order',
initial: 'pending',
states: {
pending: { on: { PAY: 'paid' } }, // 待支付只能去已支付
paid: { on: { SHIP: 'shipped', REFUND: 'refunded' } },
shipped: { on: { CONFIRM: 'done' } },
refunded: { type: 'final' },
done: { type: 'final' },
},
});
// 非法转换(如 pending 直接 SHIP)会被状态机自动忽略,杜绝了无效状态状态机把"当前状态"和"允许的转换"显式建模,避免了用多个布尔标志(isLoading、isError、isSuccess)拼凑状态时容易出现的"不可能状态"(比如同时 isLoading 又 isSuccess)。
真实案例:电商购物车重构
某电商团队最初把整个应用状态(用户、商品列表、购物车、订单、UI 弹窗)全塞进一个 Redux store,代码里充斥着几百个 action type,加一个功能要改 5 个文件。页面在商品列表滚动时明显卡顿,因为购物车数量变化触发了整个列表重渲染。
重构方案按状态类型拆分:
重构后效果对比:
| 指标 | 重构前 | 重构后 |
|------|--------|--------|
| 状态相关代码行数 | 约 4200 行 | 约 2100 行 |
| 商品列表滚动帧率 | 40 fps 左右 | 稳定 60 fps |
| 新增一个数据接口的改动文件数 | 5 个 | 1 个 |
| 重复请求(相同数据) | 频繁 | 命中缓存,几乎为 0 |
结论并不是"Redux 不好",而是"用错了工具"——把有缓存诉求的服务器状态硬塞进全局 store 是问题根源。
主流方案数据对比
| 方案 | 包体积(min+gzip) | 需要 Provider | 学习曲线 | 选择器优化 | 适用规模 |
|------|------------------|---------------|----------|------------|----------|
| useState/useReducer | 0 (内置) | 否 | 极低 | 不适用 | 组件级 |
| Context | 0 (内置) | 是 | 低 | 需手动拆分 | 低频全局 |
| Redux Toolkit | 约 13 KB | 是 | 中高 | 内置 | 大型 |
| Zustand | 约 1.2 KB | 否 | 低 | 内置 | 中小型 |
| Jotai | 约 3.4 KB | 可选 | 低 | 原子级 | 中型 |
| React Query | 约 13 KB | 是 | 中 | 按 queryKey | 服务器状态 |
(包体积为大致量级,随版本变化,仅供横向对比参考。)
常见坑
最佳实践
选择建议
进阶:用 useReducer + Context 手写迷你 Redux
在引入第三方库之前,其实用 React 内置能力就能搭一个"小型 Redux"。核心是把 useReducer 的 state 和 dispatch 通过两个独立 Context 传下去(拆开是为了让只 dispatch 不读 state 的组件不重渲染)。
import { createContext, useContext, useReducer } from 'react';
const StateContext = createContext(null);
const DispatchContext = createContext(null);
const initialState = { user: null, cart: [], theme: 'light' };
function rootReducer(state, action) {
switch (action.type) {
case 'LOGIN':
return { ...state, user: action.payload };
case 'ADD_TO_CART':
return { ...state, cart: [...state.cart, action.payload] };
case 'TOGGLE_THEME':
return { ...state, theme: state.theme === 'light' ? 'dark' : 'light' };
default:
return state;
}
}
export function StoreProvider({ children }) {
const [state, dispatch] = useReducer(rootReducer, initialState);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}
// dispatch 引用稳定,只 dispatch 的组件永远不会因 state 变化而重渲染
export const useAppState = () => useContext(StateContext);
export const useDispatch = () => useContext(DispatchContext);
function CartButton() {
const dispatch = useDispatch(); // 不订阅 state
return (
<button onClick={() => dispatch({ type: 'ADD_TO_CART', payload: { id: 1 } })}>
加入购物车
</button>
);
}这套方案适合中小型应用,零依赖、可测试。但它没有选择器机制,任何 state 变化都会让所有读 state 的组件重渲染——这也是为什么规模上去后要换成带选择器的 Zustand 或 Redux。
Immer 与不可变更新原理
React 判断状态是否变化靠的是引用比较(Object.is),所以更新对象/数组必须返回新引用,这就是"不可变更新"。层级一深,手写扩展运算符会非常痛苦:
// ❌ 深层嵌套的手动不可变更新,又长又易错
function reducer(state, action) {
return {
...state,
user: {
...state.user,
address: {
...state.user.address,
city: action.payload,
},
},
};
}
// ✅ 用 Immer:写"可变"代码,产出不可变结果
import { produce } from 'immer';
function reducerWithImmer(state, action) {
return produce(state, (draft) => {
draft.user.address.city = action.payload;
});
}Immer 的原理是用 Proxy 代理一个 draft 草稿对象,记录你对草稿的所有修改,最后基于这些修改生成一个结构共享的新对象——没改动的部分复用旧引用,改动的路径才创建新对象。Redux Toolkit 的 createSlice 内部就默认集成了 Immer,这正是你能在 reducer 里"直接 push"的原因。
状态规范化(Normalization)
当服务器数据是"列表 + 引用"结构时(比如帖子里引用了作者),把它们扁平化成"以 id 为键的字典"能大幅简化更新逻辑,避免同一份数据存多份导致的不一致。
// ❌ 嵌套结构:同一个 user 在多条 post 里重复,改名要遍历所有 post
const bad = {
posts: [
{ id: 1, title: 'A', author: { id: 10, name: 'Tom' } },
{ id: 2, title: 'B', author: { id: 10, name: 'Tom' } },
],
};
// ✅ 规范化结构:user 只存一份,post 只存引用 id
const good = {
posts: {
byId: {
1: { id: 1, title: 'A', authorId: 10 },
2: { id: 2, title: 'B', authorId: 10 },
},
allIds: [1, 2],
},
users: {
byId: { 10: { id: 10, name: 'Tom' } },
},
};
// 改名只需一处
function renameUser(state, id, name) {
return produce(state, (draft) => {
draft.users.byId[id].name = name;
});
}Redux Toolkit 提供了 createEntityAdapter 来自动化这套规范化操作,内置 addOne / updateOne / removeOne / selectAll 等标准 CRUD 方法和高性能选择器。
派生状态 vs 冗余状态
一条重要原则:能算出来的就别存。如果某个值可以由现有状态推导,就不要把它单独存成一份状态,否则两者容易不同步。
// ❌ 冗余:把 fullName 也存进状态,firstName 改了忘了同步就出 bug
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState(''); // 多余!
// ✅ 派生:渲染时直接算
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const fullName = `${firstName} ${lastName}`; // 永远同步
// 如果计算昂贵,用 useMemo 缓存
const expensiveDerived = useMemo(
() => heavyCompute(list),
[list]
);SWR:另一个服务器状态方案
SWR(stale-while-revalidate)是 Vercel 出品的数据请求库,理念和 React Query 类似——先返回缓存(stale),再后台请求最新数据(revalidate)。它比 React Query 更轻量,API 更精简。
import useSWR from 'swr';
const fetcher = (url) => fetch(url).then((r) => r.json());
function Profile({ id }) {
const { data, error, isLoading, mutate } = useSWR(
`/api/user/${id}`,
fetcher,
{ revalidateOnFocus: true, dedupingInterval: 2000 }
);
if (isLoading) return <div>加载中...</div>;
if (error) return <div>加载失败</div>;
return (
<div>
<h1>{data.name}</h1>
<button onClick={() => mutate()}>刷新</button>
</div>
);
}React Query vs SWR 对比:
| 维度 | React Query | SWR |
|------|-------------|-----|
| 包体积 | 较大(约 13 KB) | 较小(约 4 KB) |
| Mutation 支持 | 强大(useMutation) | 需手动 mutate |
| 分页/无限滚动 | 内置 useInfiniteQuery | 有 useSWRInfinite |
| 缓存控制 | 精细(staleTime/gcTime) | 相对简单 |
| Devtools | 官方专用面板 | 社区方案 |
| 适用场景 | 复杂数据交互 | 以读为主的场景 |
持久化与 SSR 的坑
把状态持久化到 localStorage 时,在 Next.js 等 SSR 框架里要特别小心 hydration 不匹配:服务端渲染时读不到 localStorage,客户端首帧却读到了,两边 HTML 不一致会报错。
// Zustand persist 在 SSR 下的安全写法
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useStore = create(
persist(
(set) => ({ count: 0, inc: () => set((s) => ({ count: s.count + 1 })) }),
{
name: 'app-store',
skipHydration: true, // 手动控制 hydration 时机
}
)
);
// 组件里在客户端挂载后再读取,避免首帧不匹配
function Counter() {
const [mounted, setMounted] = useState(false);
const count = useStore((s) => s.count);
useEffect(() => setMounted(true), []);
if (!mounted) return null; // 首帧返回占位,避免 hydration mismatch
return <div>{count}</div>;
}性能测量与调试技巧
选型和优化都要靠数据说话,不要凭感觉。常用手段:
// 用 Profiler API 在代码里量化渲染耗时
import { Profiler } from 'react';
function onRender(id, phase, actualDuration) {
console.log(`${id} [${phase}] 耗时 ${actualDuration.toFixed(2)}ms`);
}
function App() {
return (
<Profiler id="ProductList" onRender={onRender}>
<ProductList />
</Profiler>
);
}总结
| 关键要点 | 说明 |
|----------|------|
| 状态要分类 | UI / 全局 / 服务器 / URL 四类,各有专属工具 |
| Context 是传递不是管理 | 适合低频全局,高频状态会引发大面积重渲染 |
| 服务器状态用专业库 | React Query/SWR 处理缓存、失效、乐观更新 |
| 全局状态选轻量库 | Zustand/Jotai 无 Provider、带选择器、体积小 |
| 大型项目用 Redux Toolkit | 生态成熟、时间旅行调试、中间件丰富 |
| 状态就近原则 | 能局部就不全局,重渲染范围越小越好 |
状态管理没有银弹,核心是"用对的工具管对的状态"。理解状态的分类和各方案的取舍,比记住某个库的 API 更重要。
Zustand 进阶:中间件、slices 与选择器优化
前面已经用过 Zustand 的基础 API,这里深入它真正拉开差距的地方:中间件组合、大型 store 的 slices 拆分、选择器与浅比较。Zustand 的运行时体积约 1.2KB(gzip),核心就是一个发布订阅 + `useSyncExternalStore`,中间件只是对 `set/get` 的层层包装。
中间件洋葱模型
Zustand 中间件本质是 `(config) => (set, get, api) => config(wrappedSet, wrappedGet, api)`,一层套一层像洋葱。顺序很关键:`devtools` 要放最外层才能记录所有 action,`persist` 要在 `immer` 外面才能序列化到普通对象。
import { create } from 'zustand';
import { devtools, persist, subscribeWithSelector } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
interface BearState {
bears: number;
fish: number;
addBear: () => void;
eatFish: () => void;
}
// 中间件从内到外:immer -> persist -> subscribeWithSelector -> devtools
export const useBearStore = create<BearState>()(
devtools(
subscribeWithSelector(
persist(
immer((set) => ({
bears: 0,
fish: 10,
// immer 中间件让你能直接"修改"草稿,底层仍是不可变更新
addBear: () => set((s) => { s.bears += 1; }, false, 'addBear'),
eatFish: () => set((s) => { s.fish -= 1; }, false, 'eatFish'),
})),
{
name: 'bear-storage',
partialize: (s) => ({ bears: s.bears }), // 只持久化 bears,不存 fish
version: 2,
migrate: (persisted, from) => {
// 从旧版本迁移,避免用户 localStorage 里的老结构报错
if (from < 2) return { bears: (persisted as any).bears ?? 0 };
return persisted as any;
},
}
)
),
{ name: 'BearStore' } // devtools 里显示的实例名
)
);`set` 的第二、三个参数(`false, 'addBear'`)是 devtools 专用:`false` 表示不替换整个 state(浅合并),字符串是 action 名,会显示在 Redux DevTools 时间线里。
slices 模式:拆分大型 store
当 store 超过 300 行,把它切成多个 slice,每个 slice 是一个返回部分 state 的函数,再在 `create` 里合并。这样团队可以并行维护、单独测试。
import { create, StateCreator } from 'zustand';
interface CartSlice {
items: string[];
addItem: (id: string) => void;
}
interface UserSlice {
userId: string | null;
login: (id: string) => void;
}
type Store = CartSlice & UserSlice;
const createCartSlice: StateCreator<Store, [], [], CartSlice> = (set) => ({
items: [],
addItem: (id) => set((s) => ({ items: [...s.items, id] })),
});
const createUserSlice: StateCreator<Store, [], [], UserSlice> = (set) => ({
userId: null,
login: (id) => set({ userId: id }),
});
export const useStore = create<Store>()((...a) => ({
...createCartSlice(...a),
...createUserSlice(...a),
}));选择器优化:useShallow 与 transient 更新
Zustand 默认用 `Object.is` 比较选择器返回值。如果选择器返回一个新对象/数组,每次都会触发重渲染。用 `useShallow` 做浅比较可以避免:
import { useShallow } from 'zustand/react/shallow';
// 错误:每次 render 都返回新对象,永远不相等 -> 每次都重渲染
const { bears, fish } = useBearStore((s) => ({ bears: s.bears, fish: s.fish }));
// 正确:useShallow 逐字段浅比较,只有 bears/fish 真正变化才重渲染
const { bears, fish } = useBearStore(
useShallow((s) => ({ bears: s.bears, fish: s.fish }))
);对于"高频变化但不需要触发渲染"的场景(比如鼠标坐标、滚动位置),可以用 transient 更新:直接 `subscribe` 拿值写进 ref,绕过 React 渲染。实测一个每秒更新 60 次的坐标状态,用 transient 后组件重渲染次数从 60 次/秒 降到 0 次/秒。
function Cursor() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
// subscribeWithSelector 中间件让 subscribe 支持选择器
return useCursorStore.subscribe(
(s) => s.position,
(pos) => { if (ref.current) ref.current.style.transform = `translate(${pos.x}px,${pos.y}px)`; }
);
}, []);
return <div ref={ref} className="cursor" />;
}Jotai 原子模型深入
Zustand 是"自上而下"的单一 store,Jotai 是"自下而上"的原子(atom)组合。每个 atom 是最小状态单元,组件只订阅它用到的 atom,天然避免大范围重渲染。Jotai 核心约 3KB(gzip)。
派生 atom 与只读/可写 atom
import { atom } from 'jotai';
const priceAtom = atom(100);
const countAtom = atom(2);
// 只读派生 atom:依赖变化时自动重算,带缓存
const totalAtom = atom((get) => get(priceAtom) * get(countAtom));
// 读写派生 atom:get 读,set 写回多个源 atom
const discountedAtom = atom(
(get) => get(totalAtom) * 0.8,
(get, set, newTotal: number) => {
set(priceAtom, newTotal / get(countAtom) / 0.8);
}
);派生 atom 的重算是惰性且带记忆的:只有被组件订阅、且依赖真正变化时才重算,多个组件读同一个派生 atom 只算一次。
atomFamily 与异步 atom
`atomFamily` 用参数生成一族 atom,适合"按 id 管理列表项状态";异步 atom 直接把 Promise 当值,配合 Suspense 使用。
import { atomFamily, atomWithStorage } from 'jotai/utils';
import { atom } from 'jotai';
// 每个 todoId 对应一个独立 atom,互不影响重渲染
const todoAtomFamily = atomFamily((id: string) =>
atom({ id, done: false })
);
// 异步 atom:组件用 useAtom 读取时自动触发 Suspense
const userAtom = atom(async (get) => {
const res = await fetch(`/api/user/${get(userIdAtom)}`);
return res.json();
});
// 持久化 atom:值自动同步到 localStorage
const themeAtom = atomWithStorage('theme', 'light');Redux Toolkit 现代写法
RTK 是官方推荐的 Redux 写法,把样板代码压缩了约 70%。核心是 `createSlice`(自动生成 action creators + reducer,内置 Immer)、`createAsyncThunk`(异步流程)、`RTK Query`(服务器状态)、`createEntityAdapter`(规范化 CRUD)、`listener middleware`(副作用)。
createSlice + createEntityAdapter
import { createSlice, createEntityAdapter, createAsyncThunk } from '@reduxjs/toolkit';
interface Todo { id: string; title: string; done: boolean; }
// entityAdapter 提供规范化的 { ids: [], entities: {} } 结构 + CRUD reducers
const todosAdapter = createEntityAdapter<Todo>({
sortComparer: (a, b) => a.title.localeCompare(b.title),
});
export const fetchTodos = createAsyncThunk('todos/fetch', async () => {
const res = await fetch('/api/todos');
return (await res.json()) as Todo[];
});
const todosSlice = createSlice({
name: 'todos',
initialState: todosAdapter.getInitialState({ loading: false }),
reducers: {
// 直接"改" state,Immer 生成不可变更新
toggle: (state, action) => {
const t = state.entities[action.payload];
if (t) t.done = !t.done;
},
addOne: todosAdapter.addOne, // 直接复用 adapter 的 reducer
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (s) => { s.loading = true; })
.addCase(fetchTodos.fulfilled, (s, a) => {
s.loading = false;
todosAdapter.setAll(s, a.payload);
});
},
});
// adapter 自动生成记忆化的 selectors
export const { selectAll, selectById } = todosAdapter.getSelectors();
export const { toggle, addOne } = todosSlice.actions;
export default todosSlice.reducer;RTK Query:内置的服务器状态方案
RTK Query 定位类似 React Query,但深度集成在 Redux store 里,自动生成 hooks、缓存、失效与轮询。
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Todo'],
endpoints: (build) => ({
getTodos: build.query<Todo[], void>({
query: () => 'todos',
providesTags: ['Todo'],
}),
addTodo: build.mutation<Todo, Partial<Todo>>({
query: (body) => ({ url: 'todos', method: 'POST', body }),
invalidatesTags: ['Todo'], // 新增后自动让 getTodos 缓存失效并重取
}),
}),
});
export const { useGetTodosQuery, useAddTodoMutation } = api;listener middleware:声明式副作用
RTK 内置的 listener middleware 可以替代大部分 redux-saga/redux-observable 场景,体积却小得多。
import { createListenerMiddleware } from '@reduxjs/toolkit';
export const listenerMiddleware = createListenerMiddleware();
// 监听 login 成功,自动去拉取用户配置
listenerMiddleware.startListening({
actionCreator: loginSuccess,
effect: async (action, api) => {
api.cancelActiveListeners(); // 防抖:取消上一次未完成的
await api.delay(300);
api.dispatch(fetchUserSettings(action.payload.userId));
},
});React Query 服务器状态进阶
前面介绍了 React Query 的基础用法,这里深入四个高频进阶场景:乐观更新、无限滚动、预取、依赖查询与缓存失效。React Query 把服务器状态和客户端状态彻底分开,默认 `staleTime: 0`、`gcTime: 5min`(v5 里 `cacheTime` 更名为 `gcTime`)。
乐观更新(optimistic update)
先改本地缓存让 UI 立刻响应,失败再回滚。实测在 300ms 网络延迟下,用户感知的点赞响应从 300ms 降到 0ms。
const mutation = useMutation({
mutationFn: (id: string) => api.like(id),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['post', id] }); // 取消在途请求
const prev = queryClient.getQueryData(['post', id]);
queryClient.setQueryData(['post', id], (old: any) => ({ ...old, liked: true }));
return { prev }; // 返回快照,供出错回滚
},
onError: (_e, id, ctx) => {
queryClient.setQueryData(['post', id], ctx?.prev); // 回滚
},
onSettled: (_d, _e, id) => {
queryClient.invalidateQueries({ queryKey: ['post', id] }); // 最终以服务器为准
},
});无限滚动与分页
`useInfiniteQuery` 专门处理"加载更多",用 `getNextPageParam` 计算下一页游标。
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => api.getFeed({ cursor: pageParam }),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
// data.pages 是二维结构,展平后渲染
const items = data?.pages.flatMap((p) => p.items) ?? [];预取与依赖查询
预取(prefetch)在用户点击前就把数据放进缓存,比如 hover 列表项时预取详情,点进去几乎 0 等待。
// hover 时预取详情,进入详情页直接命中缓存
function Row({ id }: { id: string }) {
const qc = useQueryClient();
const prefetch = () =>
qc.prefetchQuery({ queryKey: ['detail', id], queryFn: () => api.getDetail(id) });
return <li onMouseEnter={prefetch}>...</li>;
}
// 依赖查询:user 加载完才查 projects(enabled 控制)
const { data: user } = useQuery({ queryKey: ['user'], queryFn: api.getUser });
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => api.getProjects(user!.id),
enabled: !!user?.id, // user 就绪前不发请求
});Context 深度优化:useContextSelector
原生 Context 有个硬伤:只要 `value` 引用变化,所有消费该 Context 的组件都会重渲染,即使它只用了 value 里的一个字段。除了前面提到的"拆分 Context""value 用 useMemo 包裹",还有一个更彻底的方案 —— `use-context-selector`。
它让消费者只订阅 value 的某个切片,实测在一个含 50 个消费者的 Context 里,改动其中一个字段,重渲染组件数从 50 降到 1。
import { createContext, useContextSelector } from 'use-context-selector';
const StoreContext = createContext<{ count: number; name: string }>(null!);
function Count() {
// 只订阅 count,name 变化时本组件不重渲染
const count = useContextSelector(StoreContext, (v) => v.count);
return <span>{count}</span>;
}React 官方也在推进内置的 `use` + context selector 能力,但在落地前,`use-context-selector` 仍是生产可用的补丁方案。
选型决策树与量化对比
面对这么多方案,用一棵决策树快速定位:
是服务器数据(需缓存/失效/重取)吗?
├── 是 → React Query / SWR / RTK Query
└── 否 → 需要跨组件共享吗?
├── 否 → useState / useReducer(就近原则)
└── 是 → 更新频率高吗 / 需要精确重渲染吗?
├── 低频且简单 → Context + useReducer
├── 中等,想要轻量无 Provider → Zustand
├── 组件级细粒度、大量派生 → Jotai
└── 大型团队、需时间旅行/中间件生态 → Redux Toolkit各方案的量化对比(数字为大致量级,供选型参考):
| 方案 | 运行时体积(gzip) | Provider | 选择器 | 学习曲线 | 适用规模 |
|------|------------------|----------|--------|----------|----------|
| useState/useReducer | 0(内置) | 无 | 无 | 低 | 局部状态 |
| Context | 0(内置) | 需要 | 需第三方 | 低 | 低频全局 |
| Zustand | 约 1.2KB | 不需要 | 内置 | 低 | 中小到中大型 |
| Jotai | 约 3KB | 需要 | 原子天然 | 中 | 细粒度/派生多 |
| Redux Toolkit | 约 13KB | 需要 | reselect | 中高 | 大型团队 |
| React Query | 约 12KB | 需要 | select 选项 | 中 | 服务器状态 |
重渲染成本对比(同一场景实测量级)
场景:一个 500 项的列表,勾选其中一项。不同方案下"实际重渲染的组件数":
| 方案 | 重渲染组件数 | 说明 |
|------|--------------|------|
| 单一 Context 存整个列表 | 约 500 | value 变化触发全部消费者 |
| Zustand + useShallow 选择器 | 1 | 只有被勾选项重渲染 |
| Jotai atomFamily | 1 | 每项独立 atom |
| Redux + 记忆化 selector | 1~2 | selectById 精确订阅 |
结论一致:能把订阅粒度做细,就能把重渲染压到最低。这也是除 Context 外的库普遍内置选择器的根本原因。
进阶最佳实践清单
| 实践 | 说明 |
|------|------|
| 服务器状态和客户端状态分开 | 别把 React Query 的数据再 copy 进 Zustand |
| 选择器只返回原始值或用浅比较 | 返回新对象不加 useShallow 必然多余渲染 |
| store 按领域切 slice | 单文件不超过 300 行,便于并行维护 |
| 持久化用 partialize 白名单 | 只存必要字段,避免存入派生/临时状态 |
| 乐观更新必配回滚 + invalidate | onError 回滚,onSettled 以服务器为准 |
| 高频非渲染状态走 transient | 坐标/滚动用 subscribe 写 ref,绕过渲染 |
小结
深入到进阶层面,各库的差异其实收敛到同一组问题上:订阅粒度、更新的不可变性、副作用的组织方式、服务器与客户端状态的边界。选库时不必纠结"哪个更好",而应问"我的状态属于哪一类、更新有多频繁、团队要不要时间旅行调试"。把这几个问题回答清楚,工具自然浮现出来。