Redux、MobX、Recoil、Zustand 、Jotai 对比

这五个库里有一个已经归档,一个的官方文档写着「别再直接用这个包」,还有一个刚发的大版本把装饰器删干净了。先把版本对齐,再谈选型。

预计
10 分钟

横向对比状态管理库的文章有个通病:拿五年前的 API 比五年后的库。这五个里,Recoil 已经被归档,Redux 官方明写着不要再直接用 redux 包,MobX 刚在 7.0 里把老装饰器删干净。所以先对齐版本,再谈别的。

最新版本 发布时间 现在的入口
Redux redux 5.0.1 2023-12-23 @reduxjs/toolkit 2.12.0
MobX mobx 7.0.0 2026-07-30 makeAutoObservable 或 Stage 3 装饰器
Recoil recoil 0.7.7 2023-03-01 已归档,无
Zustand zustand 5.0.15 2026-08-13 create
Jotai jotai 2.20.2 2026-07-14 atom + createStore

Redux 的官方建议是别再直接用 redux

Redux 的模型没变:状态存在一个 store 里,改状态只能派发 action,reducer 是纯函数,拿旧状态和 action 算出新状态。这套东西二十行就能写出来,而且这二十行是真的能跑:

Redux 的最小实现(示意,不是源码)
function createStore(reducer) {
let state = reducer(undefined, {});
const subscribers = [];
function dispatch(action) {
state = reducer(state, action);
subscribers.forEach((subscriber) => subscriber());
}
function subscribe(callback) {
subscribers.push(callback);
}
function getState() {
return state;
}
return { dispatch, subscribe, getState };
}

dispatch 里那两行就是 Redux 的全部:算出新状态,然后通知所有人。剩下的中间件、DevTools、时间旅行,都是围着这两行长出来的。

变的是你该怎么用它。createStoreredux 4.2.0(2022-04-18) 起带上了 @deprecated 标记,官方文档的说法很直白:

We specifically recommend that our users should use Redux Toolkit (the @reduxjs/toolkit package), and should not use the legacy redux core package for any new Redux code today!

两个容易被夸大的地方要说清。第一,这个弃用只是一个 JSDoc 标记,效果是编辑器里给你划一道删除线,运行时不打印任何警告;嫌碍眼可以改成 import { legacy_createStore as createStore } from 'redux',那个函数体就是原样转调 createStore,行为完全一致。第二,官方明说了 createStore 不会被移除。所以「Redux 废弃了 createStore,老代码跑不了了」是误传,老代码照跑。

新代码该长这样:

@reduxjs/toolkit · createSlice + configureStore
import { createSlice, configureStore } from '@reduxjs/toolkit';
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
// 这里可以直接改 state —— 底下是 Immer 在录制草稿
increment: (state) => { state.value += 1 },
addBy: (state, action) => { state.value += action.payload },
},
});
export const { increment, addBy } = counterSlice.actions;
const store = configureStore({ reducer: { counter: counterSlice.reducer } });

createSlice 一次性生成 reducer 和对应的 action creator,action type 由 name 加方法名拼出来(counter/increment),省掉了手写常量那一层。configureStore 则默认装好了 thunk 中间件和 DevTools,还在开发构建里加了两道检查:state 里混进不可序列化的东西会警告,reducer 里意外改了原对象也会警告。

代价是 Immer 进了依赖,而且「看起来在改 state」这件事对新人是个认知负担——那个 state 是草稿代理,不是真状态。

MobX 的装饰器故事分三段

MobX 走的是另一条路:不派发 action,直接读写对象属性,靠依赖追踪知道谁该更新。最小实现的关键是读的时候登记,写的时候通知

依赖追踪的最小实现(示意,不是源码)
let activeReaction = null;
function observable(obj) {
const observers = new Map(); // key -> Set<reaction>
return new Proxy(obj, {
get(target, key) {
if (activeReaction) {
if (!observers.has(key)) observers.set(key, new Set());
observers.get(key).add(activeReaction);
}
return target[key];
},
set(target, key, value) {
target[key] = value;
observers.get(key)?.forEach((fn) => fn());
return true;
},
});
}
function autorun(fn) {
const reaction = () => {
activeReaction = reaction;
try { fn(); } finally { activeReaction = null; }
};
reaction();
}

贴进控制台跑一遍:const s = observable({ count: 0 }); autorun(() => console.log(s.count)); 先打印 0,然后 s.count = 1 会自动再打印 1。activeReaction 这个全局变量是整套机制的枢纽——只有在某个 reaction 正在跑的时候,get 才会登记依赖。少了它,observers 永远是空的,set 也就通知不到任何人。

真实的 MobX 在这个骨架上加了批处理、计算值缓存和环检测,但登记与通知这两步是一样的。

装饰器这条线分三段,很多文章卡在第一段:

  • MobX 4 / 5@observable@action 这套传统装饰器是主力写法。
  • MobX 6(2020-09-30):装饰器改成 opt-in。主推 makeObservable(this, { … })makeAutoObservable(this),在构造函数里调一次,把字段标注出来。想继续用装饰器也行,但要在工具链里开传统装饰器,并且仍然要在构造函数里调 makeObservable(this) 去读装饰器留下的元数据。
  • MobX 7(2026-07-30):传统装饰器不再支持,改用 TC39 Stage 3 的新装饰器;用新装饰器时不需要再调 makeObservable。同时 observable 一律走 Proxy,configure({ useProxies }) 没了。

所以现在写 MobX,不碰装饰器的话是这样:

MobX 6/7 · makeAutoObservable
import { makeAutoObservable } from 'mobx';
class Counter {
constructor() { makeAutoObservable(this) }
value = 0;
increment() { this.value += 1 } // 自动被推断成 action
get doubled() { return this.value * 2 } // 自动被推断成 computed
}

makeAutoObservable 会遍历实例上的字段自己推断:普通字段变 observable,方法变 action,getter 变 computed。省事,代价是推断不总是你想要的,要覆盖就得传第二个参数。

Recoil 停在 2023 年,2025 年元旦被归档

这一条影响选型,值得单独说。

facebookexperimental/Recoil 仓库在 2025-01-01 被归档,页面上挂着「This repository was archived by the owner on Jan 1, 2025. It is now read-only.」。npm 上最后一个版本是 0.7.7,2023-03-01 发布main 分支最后一次真实提交是 2023-09-07。

有几件事要按事实说:Meta 没有发过任何正式的停止维护声明,仓库是静悄悄归档的,官网到现在也没挂弃用提示,npm 包上同样没有 deprecated 标记。能引的最接近的表态来自贡献者 SamChou19815 在 2024-02 的一条 issue 回复,大意是这个项目没人维护了、内外仓库的同步也停了。他不是原作者,也不是以 Meta 名义发言,所以只能当作旁证。至于「Meta 裁掉了 Recoil 团队」那个说法,我没有找到可靠出处,不写。

结论对选型足够清楚了:新项目不要从 Recoil 起步。 它的 atom / selector 模型很好,好在这个模型被 Jotai 继承下来了,而 Jotai 还在更新。

Zustand 的 v5 没动 create,动的是默认导出

Zustand 的 store 就是一个闭包,装着当前状态和一组监听器:

Zustand vanilla store 的最小实现(示意,不是源码)
function createStore(createState) {
const listeners = new Set();
let state;
const setState = (partial) => {
const next = typeof partial === 'function' ? partial(state) : partial;
state = Object.assign({}, state, next); // 默认浅合并,不是整体替换
listeners.forEach((l) => l());
};
const getState = () => state;
const subscribe = (l) => { listeners.add(l); return () => listeners.delete(l) };
state = createState(setState, getState);
return { setState, getState, subscribe };
}

注意 state = createState(setState, getState) 这一句:你写的那个初始化函数拿到 setget,返回的对象里既有数据也有方法,两者放在同一层。这是 Zustand 和其它几个的最大区别——它不区分「状态」和「操作状态的东西」。

setState 默认浅合并而不是替换,想整体替换要传第二个参数 true。真实的 React 绑定在这个 vanilla store 外面包一层,用 useSyncExternalStore 订阅,selector 返回值变了才重渲染。

关于 v4 到 v5,有一个说法要纠正:create 的签名没变。 那个「柯里化」写法 create<T>()(fn) 从 4.0.0 就在了,v4 和 v5 的类型声明逐字相同;它存在的理由是 TypeScript 的一个泛型推断限制,运行时什么都不做。真正在 v5 里被拿掉的是这些:

  • 默认导出没了。 import create from 'zustand' 要改成 import { create } from 'zustand'。具名导出 4.3.0 就加了,默认导出同期标记弃用,4.3.3 起会在运行时警告,5.0.0 删除。
  • 最低 React 18,use-sync-external-store 从依赖降级成可选的 peer(只有 zustand/traditional 还需要它)。
  • hook 上那个第三参数 equalityFn 没了,要浅比较改用 useShallow
  • zustand/context 整个入口、StoreApi.destroy()、以及 persist 的 getStorage / serialize / deserialize 都删了。

import { createStore } from 'zustand' 仍然可用——被删的是 zustand/vanilla 子模块的默认导出,不是 createStore 本身。

Recoil 和 Jotai 的最小实现几乎是同一段代码

两个库都基于原子化:状态拆成独立的最小单元,派生值由函数算出来。最小实现也就长一个样:

原子化的最小实现(示意,不是源码)
function atom(initialValue) {
let value = initialValue;
const subscribers = new Set();
return {
get: () => value,
set(newValue) {
value = newValue;
subscribers.forEach((s) => s());
},
subscribe(callback) {
subscribers.add(callback);
return () => subscribers.delete(callback);
},
};
}

差别只在命名:Recoil 叫 useRecoilState,Jotai 叫 useAtom;Recoil 的派生单元叫 selector,Jotai 直接用 atom(get => …) 表示。

但这段示意有一处和真实的 Jotai 相反,值得点出来:真实的 atom 里不存值,也不存订阅者。 atom() 返回的只是一份 { read, write } 配置,值存在 store 的一个 WeakMap 里,订阅也登记在 store 上。这个区别不是细节——正因为值不在 atom 上,同一个 atom 才能在两个 store 里有两份互不相干的值,测试之间的隔离也才做得到。展开讲在 Jotai v2:React 状态管理的新篇章

Recoil 的 atom 则必须带一个全局唯一的 key 字符串,重复了会在运行时报错——这也是它和 Jotai 在使用手感上最直接的差异。

五个库在四个维度上的差别

状态存在哪 订阅粒度 要不要 Provider 异步写在哪
Redux 一棵集中的 state 树 selector 的返回值 <Provider> 中间件(thunk / RTK Query)
MobX 可观察对象自身 实际被读到的属性 不要 随便,action 里直接 await
Recoil RecoilRoot 持有的 store 单个 atom <RecoilRoot> selector 支持 async
Zustand 模块作用域里的闭包 selector 的返回值 不要 随便,写在 store 的方法里
Jotai store 里的 WeakMap 单个 atom 不要(有默认 store) read 可以是 async,配 Suspense

「不要 Provider」这一列有代价:状态挂在模块作用域上,服务端渲染时多个请求会共用同一份。Zustand 和 Jotai 都得靠额外手段隔离——Jotai 是每个请求一个 store,Zustand 是把 store 建在请求作用域里再用 context 传下去。

怎么选

按项目形态选,不按「大型 / 中型 / 小型」这种没有操作性的标签选。

已经在跑 Redux 的项目:迁到 Redux Toolkit,不要重写。createSlice 可以和老 reducer 共存,一个模块一个模块换。

新项目,团队里有人熟悉 Redux 生态:直接上 RTK。它值钱的地方不是少写样板,是那条完整的 action 日志——出了问题能回放,这一点其它四个都给不了。

状态改动频繁、读的地方分散:MobX 的心智负担最低,写起来像在改普通对象。代价是变更点不显式,谁改了这个字段只能靠 devtools 追;而且现在要留意 6 和 7 的装饰器差异,团队里版本不统一会很难受。

想要最少的概念:Zustand。一个 create,一个 hook,没有 Provider 也没有原子图。它的边界在于全局单例——SSR 和测试隔离要额外处理。

重渲染是主要矛盾:Jotai。订阅粒度压到单个 atom,绕开 Context 那种整棵子树一起重渲染的问题。代价是派生关系散在各个 atom 的 read 里,值算错了要顺着依赖一层层往回找。

Recoil:不选。模型很好,但仓库已经归档,Jotai 是它的现实替代。

最后一句还是原来那句,它没过时:一个项目里混用两套状态管理是允许的。表单和弹窗这类局部状态用 useState 就够,跨页面共享的那部分才需要库。像 dva 那样把 Redux 按领域收进 model 的框架(见 Dva 框架源码解析),本质上也是在同一套 Redux 上换了个组织方式。边界按模块划,不按库划。

这篇归在 React 与生态下。同一主题里最近的另外两篇是 Jotai v2:React 状态管理的新篇章React 18 完结:最佳实践与注意事项