在React项目中,状态管理方案的选择往往决定了后续迭代的复杂度。Redux凭借可预测的状态容器和成熟的生态,长期以来是事实标准,但围绕action、reducer、slice、selector的样板代码也让不少团队感到疲惫。Zustand以极简的API和基于hooks的使用方式,让同一件事用更少的代码就能完成。本文会通过一个用户模块的完整迁移过程,展示如何在不重写整个应用的前提下,把Redux中的状态逻辑逐步替换为Zustand,并对比两者在异步请求、持久化和调试上的实际体验。

先看一个直观的数据:在一个包含四个业务模块的中型React应用中,Redux Toolkit版本的store目录大约有400行代码,而改为Zustand后,同样的逻辑可以压缩到150行左右。这个差异主要来自样板代码的消除和API的收敛。下面从一个用户模块的代码对比开始,逐步拆解迁移路径。
一、样板代码对比:从Redux Toolkit到Zustand
Redux Toolkit虽然已经比传统Redux简化了不少,但一个完整的功能模块依然需要先定义初始状态,再创建slice,然后声明reducers和extraReducers,最后导出action和reducer。异步请求还要借助createAsyncThunk,并分别处理pending、fulfilled、rejected三种状态。这一整套流程为每个业务模块增加了大量重复代码。
Zustand的思路完全不同,它把store看作一个普通的JavaScript对象,通过create函数返回一个自定义hook。状态字段和修改状态的action都写在同一个对象里,没有action type字符串,没有switch分支,也没有需要单独维护的selector。开发者在组件中直接调用store里的函数即可更新视图,代码路径短得多。
以下是一个用户状态的Redux Toolkit实现:
// Redux Toolkit 的用户状态模块
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
const initialState = {
user: null,
loading: false,
error: null,
};
export const fetchUser = createAsyncThunk('user/fetchUser', async (id) => {
const response = await fetch(`/api/users/${id}`);
return response.json();
});
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
clearUser(state) {
state.user = null;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.user = action.payload;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
},
});
export const { clearUser } = userSlice.actions;
export default userSlice.reducer;
同样的逻辑在Zustand中只需要一个create调用,异步逻辑可以直接写在action函数体内,不再需要额外的thunk封装:
// Zustand 的用户状态 store
import { create } from 'zustand';
const useUserStore = create((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id) => {
set({ loading: true, error: null });
try {
const response = await fetch(`/api/users/${id}`);
const user = await response.json();
set({ user, loading: false });
} catch (error) {
set({ error: error.message, loading: false });
}
},
clearUser: () => set({ user: null }),
}));
从行数上看,Zustand版本减少了约一半,更重要的是它去除了action type、builder回调等概念负担。对于中小型项目或新加入的开发者来说,阅读和修改Zustand store的门槛明显更低。
二、核心概念映射与迁移步骤
迁移并不是把Redux代码一行一行翻译成Zustand,而是要先理解两者在概念上的对应关系。Redux中的initialState对应Zustand create函数返回对象里的状态字段;Redux中的reducer逻辑对应Zustand中直接修改状态的action函数;Redux中的selector对应Zustand的useStore传入的selector参数;Redux中的store配置和Provider包裹在Zustand中根本不存在,因为store本身就是一个自定义hook。
对于已经熟悉Redux的开发者,可以把迁移过程拆成几个小步骤。第一步,先为当前模块创建一个独立的Zustand store,保持与Redux slice相同的状态字段和action方法名。第二步,在组件中删除useDispatch和useSelector,改为直接调用Zustand store的hook。第三步,确认组件行为正常后,再删除Redux中对应的slice、reducer和相关的Provider配置。
下面用一个组件示例展示迁移前后的区别。Redux版本的组件需要同时使用useSelector和useDispatch:
import { useSelector, useDispatch } from 'react-redux';
import { fetchUser, clearUser } from './store/userSlice';
function UserProfile({ userId }) {
const dispatch = useDispatch();
const { user, loading, error } = useSelector((state) => state.user);
React.useEffect(() => {
dispatch(fetchUser(userId));
}, [userId, dispatch]);
if (loading) return <p>加载中...</p>;
if (error) return <p>{error}</p>;
return (
<div>
<h2>{user?.name}</h2>
<button onClick={() => dispatch(clearUser())}>退出</button>
</div>
);
}
迁移到Zustand后,组件不再需要引入React-Redux的API,直接订阅store中的具体字段即可:
import { useUserStore } from './store/userStore';
function UserProfile({ userId }) {
const user = useUserStore((state) => state.user);
const loading = useUserStore((state) => state.loading);
const error = useUserStore((state) => state.error);
const fetchUser = useUserStore((state) => state.fetchUser);
const clearUser = useUserStore((state) => state.clearUser);
React.useEffect(() => {
fetchUser(userId);
}, [userId, fetchUser]);
if (loading) return <p>加载中...</p>;
if (error) return <p>{error}</p>;
return (
<div>
<h2>{user?.name}</h2>
<button onClick={clearUser}>退出</button>
</div>
);
}
可以看到,迁移后组件里的状态读取和操作都来自同一个hook,不需要再区分哪个数据来自哪个slice,也不需要手动dispatch。对于大型模块,这种写法能明显降低组件的耦合度。
三、异步逻辑与持久化处理
Redux处理异步请求通常依赖createAsyncThunk或redux-thunk中间件,状态流转分散在pending、fulfilled、rejected三个action中。Zustand没有这些限制,你可以在action函数里直接使用async和await,在请求开始和结束时调用set更新状态。这使得异步逻辑更接近普通的JavaScript函数,阅读和调试都更自然。
对于token这类需要持久化的数据,Zustand提供了persist中间件。只需要在create外面包一层persist,就可以自动把状态同步到localStorage或sessionStorage,而不需要像Redux那样额外安装redux-persist并配置序列化、白名单等选项。
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useAuthStore = create(
persist(
(set) => ({
token: null,
login: async (username, password) => {
const res = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
set({ token: data.token });
},
logout: () => set({ token: null }),
}),
{
name: 'auth-storage',
}
)
);
persist中间件还支持partialize选项,可以只持久化部分字段,比如只保存token而不保存loading状态。这在Redux生态中往往需要额外的transform配置,而Zustand一个选项就能完成。迁移异步模块时,建议优先把登录、用户信息这类独立且边界清晰的模块先切换过去,验证好持久化和错误处理逻辑后再继续推进。
四、渐进式迁移策略与共存方案
对于正在生产环境中运行的项目,一次性把所有Redux代码替换为Zustand风险较高。更稳妥的做法是让两者共存一段时间,按模块逐步迁移。React-Redux的Provider包裹整个应用,但Zustand的store并不需要Provider,因此你可以在组件树的任意位置使用Zustand hook,而不会影响现有的Redux状态。
一种常用的渐进式策略是:先选择新功能或需求变更频繁的模块使用Zustand开发,旧模块继续使用Redux。等团队对Zustand的用法和调试流程熟悉后,再逐步把旧模块迁移过来。迁移过程中不需要一次性删除Redux的Provider,只需要确保同一个模块的状态只由一种方案管理,避免两个store同时维护同一份数据造成不一致。
如果某些全局状态必须在两种方案间同步,可以利用Redux的subscribe或Zustand的subscribe方法做桥接。例如在Redux store发生变化时,调用Zustand的setState写入共享值,反之亦然。不过这种桥接属于临时手段,只应在迁移过渡期使用,长期维护双份状态会增加理解成本。
五、常见陷阱与调试建议
Zustand的API虽然简单,但在实际使用中仍有一些容易忽略的细节。最常见的问题是过度渲染。如果在组件中直接使用useUserStore()而不传selector,组件会订阅整个store,任何状态字段变化都会触发重渲染。对于包含多个字段的store,这可能导致不必要的性能开销。正确的做法是针对实际使用的字段分别调用useUserStore并传入selector。
如果需要一次订阅多个字段,可以利用Zustand提供的shallow比较函数,避免因为返回新对象而每次渲染都触发更新:
import { shallow } from 'zustand/shallow';
// 不推荐:每次渲染都会创建新的对象,导致组件总是重新渲染
const { user, loading } = useUserStore();
// 推荐:使用shallow比较返回对象中的值是否相等
const { user, loading } = useUserStore(
(state) => ({ user: state.user, loading: state.loading }),
shallow
);
调试方面,Zustand可以通过devtools中间件连接Redux DevTools扩展,查看每一次set操作和状态快照。配置也很简单,只需要在create外面包一层devtools,并为每个set调用传入一个动作名称,就能在DevTools中清晰地追踪状态变化。
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
const useUserStore = create(
devtools(
(set) => ({
user: null,
setUser: (user) => set({ user }, false, 'setUser'),
}),
{ name: 'UserStore' }
)
);
在TypeScript项目中,建议为每个store定义一个接口,并作为泛型传给create,这样在组件中使用时可以获得完整的类型推导。Zustand对TypeScript的支持很好,但如果不显式声明状态类型,复杂对象可能会被推断为过于宽泛的结构。迁移时保留原有的类型定义,可以降低类型错误的风险。
从Redux迁移到Zustand并不是要否定Redux的价值,而是根据项目规模和团队情况选择更合适的工具。当样板代码开始影响开发效率时,Zustand提供了一条清晰的简化路径:更少的代码、更直接的异步处理、更灵活的store组织方式。通过渐进式迁移和合理的selector使用,你可以在不中断业务的前提下,逐步把状态管理代码变得更容易维护。