用 Zustand 做 React 全局状态管理:告别 Context 重渲染,3 个实战模式与持久化
组件层级一深,状态就开始满天飞。一个登录用户信息,从顶层 App 一路 props 传到七层之下的头像组件,中间六层压根用不上它,只是「过路」。你想到用 Context 收口,结果发现另一个坑:Context 里随便一个字段变了,所有订阅这个 Context 的组件------哪怕它只读了另一个不相干的字段------全部跟着重渲染。列表一长,输入框都开始卡。
Redux 能解决重渲染,但要写 action、reducer、dispatch、connect,一个简单的计数器都得铺三个文件。Zustand 是这两者之间的甜点:API 只有一个 create,没有 Provider 包裹,选择性订阅默认就带,重渲染问题天然解决。这篇讲清楚它到底怎么用、三个真实场景,以及一个新手必踩的选择器陷阱。
先看 Context 到底卡在哪
一个典型的全局 Context,存了用户信息和主题:
jsx
const AppContext = createContext(null);
function AppProvider({ children }) {
const [user, setUser] = useState({ name: "阿满", vip: false });
const [theme, setTheme] = useState("light");
// 每次 setTheme,value 都是新对象,所有 consumer 全部重渲染
return (
<AppContext.Provider value={{ user, setUser, theme, setTheme }}>
{children}
</AppContext.Provider>
);
}
问题出在 value={``{...}}:每次 render 都生成一个新对象引用,React 判定 Context 变了,于是所有 useContext(AppContext) 的组件重渲染。切换主题时,连只读 user.name 的头像组件也白白重渲染一遍。想优化只能把 Context 拆成好几个,或者上 useMemo + 手动 memo 子组件,越写越绕。
Zustand 的基本用法:一个 store 搞定
安装后,一个 store 就是一个函数:
js
// stores/useUserStore.js
import { create } from "zustand";
export const useUserStore = create((set) => ({
user: { name: "阿满", vip: false },
theme: "light",
login: (name) => set({ user: { name, vip: true } }),
toggleTheme: () =>
// set 接收当前 state 返回要合并的部分,只改 theme
set((state) => ({ theme: state.theme === "light" ? "dark" : "light" })),
}));
组件里用它,关键是传一个选择器,只取你要的字段:
jsx
function Avatar() {
// 只订阅 user.name,theme 变了这个组件纹丝不动
const name = useUserStore((s) => s.user.name);
return <span>{name}</span>;
}
function ThemeButton() {
const theme = useUserStore((s) => s.theme);
const toggleTheme = useUserStore((s) => s.toggleTheme);
return <button onClick={toggleTheme}>当前:{theme}</button>;
}
没有 Provider,不用包裹根组件,Avatar 和 ThemeButton 直接 import 就能用。切换主题时,Avatar 的选择器 s.user.name 返回值没变,Zustand 用 Object.is 比较后判定无需更新,Avatar 不重渲染。这就是它比 Context 香的核心:订阅粒度到字段级。
陷阱:选择器返回新对象,等于没优化
新手最容易踩的坑------一次性取多个字段时图省事返回一个对象:
jsx
// ❌ 每次 render 选择器都返回新对象,Object.is 永远为 false,每次 state 变都重渲染
const { name, vip } = useUserStore((s) => ({ name: s.user.name, vip: s.user.vip }));
(s) => ({...}) 每次都 new 一个对象,默认的浅相等比较认为「变了」,选择性订阅直接失效。正确做法有两种。
其一,拆成多个选择器,各订阅各的(最简单,推荐):
jsx
const name = useUserStore((s) => s.user.name);
const vip = useUserStore((s) => s.user.vip);
其二,如果确实要一次取多个,用 useShallow 做浅比较:
jsx
import { useShallow } from "zustand/react/shallow";
// useShallow 逐字段浅比较,name 和 vip 都没变时不触发重渲染
const { name, vip } = useUserStore(
useShallow((s) => ({ name: s.user.name, vip: s.user.vip }))
);
记住这条规则:选择器要么返回原始值,要么用 useShallow 包住返回的对象,否则订阅优化白做。
实战模式一:异步 action 直接写在 store 里
Zustand 的 action 就是普通函数,写异步逻辑天经地义,不用中间件:
js
export const useCartStore = create((set, get) => ({
items: [],
loading: false,
error: null,
fetchCart: async (uid) => {
set({ loading: true, error: null });
try {
const res = await fetch(`/api/cart/${uid}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
set({ items: await res.json(), loading: false });
} catch (e) {
set({ error: e.message, loading: false });
}
},
// get() 读当前最新 state,适合基于旧值算新值的场景
totalCount: () => get().items.reduce((sum, it) => sum + it.qty, 0),
}));
组件里调用 fetchCart 就行,loading / error 状态全局可见,任何组件都能读到同一份购物车,不用把请求逻辑重复散在各处。get() 让你在 action 内部拿到最新 state,做「基于当前值计算」很方便。
实战模式二:persist 中间件做本地持久化
主题、登录 token 这类希望刷新不丢的状态,套一个 persist 中间件即可,自动读写 localStorage:
js
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
export const useSettingStore = create(
persist(
(set) => ({
theme: "light",
lang: "zh",
setTheme: (theme) => set({ theme }),
}),
{
name: "app-setting", // localStorage 的 key
storage: createJSONStorage(() => localStorage),
// 只持久化 theme,lang 不落盘(比如每次跟随浏览器语言)
partialize: (s) => ({ theme: s.theme }),
}
)
);
partialize 控制只把需要的字段写进存储,避免把一次性的 loading、临时数据也塞进 localStorage。
Next.js 里用 persist 要注意:store 首次渲染在服务端没有 localStorage,水合(hydration)后客户端才读到真实值,可能出现首屏闪一下默认值。解决办法是把依赖持久化状态的 UI 放到
useEffect后再渲染,或用 store 的onRehydrateStorage标记水合完成,首屏先渲染占位,和 SSR 主题闪烁是同一类问题。
实战模式三:在组件外读写 store
Zustand 的 store 不只是 Hook,它本身挂着 getState / setState / subscribe,能在任何 React 之外的地方用------比如路由守卫、axios 拦截器、WebSocket 回调:
js
// axios 拦截器里读 token,这里没有组件、用不了 Hook
import { useUserStore } from "@/stores/useUserStore";
axios.interceptors.request.use((config) => {
const token = useUserStore.getState().token; // 直接拿最新值
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// WebSocket 推送到达时,直接写 store,组件自动更新
socket.on("notice", (msg) => {
useUserStore.setState((s) => ({ notices: [msg, ...s.notices] }));
});
这是 Context 做不到的:Context 的值只能在组件树内通过 Hook 拿。Zustand 把状态从组件树里解放出来,请求层、事件层都能直接操作同一份数据,少了一堆「把 setState 传下去」的胶水代码。
什么时候别用它
Zustand 适合中小型应用的全局状态,但不是万金油:
- 纯服务端数据 (列表、详情这种从接口拉的)优先用 React Query / SWR,它们自带缓存、失效、重试、竞态处理,比手写
fetchXxxaction 强得多。Zustand 存那些属于「客户端本地」的状态------UI 开关、选中项、购物车、登录态。 - 单个组件内部状态 别往全局塞,
useState就够,全塞 store 反而难维护。 - 需要严格的 action 审计、时间旅行调试、大团队强约定时,Redux Toolkit 的模板化反而是优点。
小结
- Context 的痛点是任一字段变化触发所有 consumer 重渲染 ;Zustand 靠选择器 +
Object.is比较做到字段级订阅,天然规避。 - 用法极简:
create定义 store,组件里useStore((s) => s.某字段)取值,无 Provider。 - 选择器返回对象必须配
useShallow,否则每次 new 对象让订阅优化失效------这是头号新手坑。 - 三个实战招:异步 action 直接写在 store、
persist中间件持久化(注意 SSR 水合闪烁)、getState/setState在组件外读写。 - 记忆点:服务端数据交给 React Query,客户端状态交给 Zustand,组件私有状态留给 useState,各司其职才不乱。