做过React状态管理的朋友应该都知道,组件之间共享数据这件事,说大不大,说小也不小。项目一复杂,useState+Context 那套很快就力不从心了——Context 一变,所有订阅它的组件全部重新渲染,根本刹不住车。Redux 能解决问题,但又带来一堆样板代码,action、reducer、dispatch 满天飞,小项目用起来就跟拿大炮打蚊子一样别扭。
jotai 就是一个专门解决这个尴尬问题的状态管理库。“jotai”在日语里是“状态”的意思,口号也很直接:极简、灵活、性能好。它采用了原子化的状态模型,你可以把每一个变量都定义成一个独立的小单元(atom),组件按需订阅自己关心的那一个,改一个不会牵连一片。API 简单到几分钟就能上手,又内置了异步处理和派生状态的能力,基本覆盖了日常开发中绝大多数场景。
这篇文章我会从 jotai 的设计思路讲起,然后用一个完整的购物车示例带你把核心功能跑一遍,最后聊聊我在实际项目里踩过的一些坑,以及它和 Redux、Zustand 这些主流方案的选型对比。无论你是刚接触状态管理的初学者,还是被样板代码烦透了的资深开发者,这篇都能给你一个清晰的参考。
1. 内容整体设计与思路拆解
1.1 为什么我会选择 jotai 而不是 Redux 或 Context
先聊一个老生常谈的问题:React 项目里到底该怎么管理全局状态?
我刚接触 React 那阵子,习惯了把共享数据往顶层 Context 一挂,子组件想用就 useContext 取一下。项目规模小的时候一切都好,组件树也不深,渲染压力不大。但一旦超过二十个组件,数据更新频率一高,问题就来了:Context 的 value 一旦变化,所有消费这个 Context 的组件都会重新渲染,哪怕你只想更新其中一个字段。就算你用 useMemo 对 value 做缓存,也架不住数据源本身频繁变化,最后整个页面都在做无用渲染,性能直接拉胯。
Redux 能解决共享和可控性的问题,但代价是极高的心智负担和样板代码。一个简单的“切换登录状态”的需求,你需要写 action type、action creator、reducer 分支、connect 或 useSelector…… 一套流程走下来,代码量翻了好几倍。很多初学朋友都会被这套东西劝退,而且 Redux 的不可变更新规则在团队协作中很容易被误用,一不小心就出现了直接修改 state 的操作,排查起来特别痛。
jotai 的思路完全不一样,它把状态拆成一个一个独立的小原子。每个 atom 只管自己的数据,组件也只订阅自己需要的那个原子。你更新 atom A,只有用到了 atom A 的组件会重渲染,和 atom B、atom C 半点关系都没有。这种按需订阅的模式,从根源上避免了 Context 的全量渲染问题。
再配合 jotai 几乎没有学习成本的 API——一个 atom() 定义状态,一个 useAtom() 在组件里读写——基本上半天就能上手干活。同时它还内置了派生状态和异步原子的支持,后端数据请求这种最常见的需求也能直接处理,不需要额外引入 redux-saga、redux-thunk 之类的中间件。
1.2 jotai 的状态原子模型如何工作
jotai 的核心概念只有一个:atom。简单理解,atom 就是一个“可订阅的数据单元”,它的大小完全由你自己决定。你可以拿它做一个布尔值来存储“弹窗是否打开”,也可以整个塞一个对象进去存储“当前用户信息”。迷你到什么粒度都行,这是你的自由,但一般来说,原子拆得越小,组件重渲染的粒度就越精准。
atom() 有三种常见形态:
- 基本原子:直接存一个值,比如 atom(0) 或 atom("")。
- 派生原子:通过函数根据其他原子计算得出新值,函数里拿到的 get 参数可以读取其他原子的值。
- 异步原子:派生原子中返回 Promise,jotai 会自动把状态变成 { data, isPending, error } 这样的加载态,我们在组件里就能直接拿到数据加载的状态。
借一个生活化的类比:把状态管理想象成一个公司的通讯录。Redux 是前台总机,所有电话都要经过总机转接,流程规范但速度慢。Context 是广播喇叭,一喊全公司都能听见,不管你想不想听。jotai 则是一人一个小电话,你直接打给需要联系的那个人,沟通精准,效率自然高。
这个模型带来的好处不仅体现在性能上,对代码组织也是一种解脱。不用再专门建一个 store 文件夹,把 action、reducer 拆得四分五裂。每个 atom 可以就近定义在使用它的组件文件旁边,或者按业务模块聚合成一个独立的 atoms 文件,怎么舒服怎么来。
2. 核心细节解析与实操要点
2.1 atom 的声明、读写与派生
用 jotai 的第一步当然是安装依赖:
npm install jotai或者
yarn add jotai安装完之后,我们需要在组件树根部包一层 Provider。注意,如果你用的是最新版本的 jotai,Provider 是可选的——jotai 有一个默认的全局 store,你不包 Provider 也能跑。但如果你需要多个互不干扰的状态隔离区(比如微前端场景,或者同一个页面里有两套相互独立的业务模块),那就必须用 Provider 来做隔离。
在项目入口文件里常规写法是这样的:
import { Provider } from 'jotai' import App from './App' export default function Root() { return ( <Provider> <App /> </Provider> ) }定义原子就更简单了:
import { atom } from 'jotai' // 基本原子:存储一个数字 export const countAtom = atom(0) // 基本原子:存储一个对象 export const userAtom = atom({ name: '', age: 0, isLogin: false, })然后在组件里读取和写入:
import { useAtom } from 'jotai' import { countAtom, userAtom } from '../atoms' function Counter() { // useAtom 返回一个元组,第一个是值,第二个是更新函数,和 useState 的用法完全一致 const [count, setCount] = useAtom(countAtom) return ( <div> <p>当前计数:{count}</p> <button onClick={() => setCount(count + 1)}>加一</button> </div> ) } function UserInfo() { const [user, setUser] = useAtom(userAtom) // 更新对象时,我们可以用展开运算符保留其他字段 const updateName = (name: string) => setUser((prev) => ({ ...prev, name })) return <div>用户名:{user.name}</div> }第一次接触的朋友可能会惊讶:就这?对,就这。useAtom 的用法和 useState 几乎一模一样,但它背后的状态是全局共享的,不在组件内部,所以你在任何组件里 useAtom 同一个 atom,拿到的都是同一份数据。
派生原子的写法也非常自然:
import { atom } from 'jotai' export const priceAtom = atom(10) export const countAtom = atom(2) // 派生原子:总价 = 单价 * 数量 export const totalPriceAtom = atom((get) => get(priceAtom) * get(countAtom))这个 totalPriceAtom 不需要单独维护,它只是一个计算属性。只要 priceAtom 或 countAtom 有任何变化,totalPriceAtom 就会自动重新计算。组件只订阅 totalPriceAtom,也只会在总价真正改变的时候才触发重渲染。
2.2 写操作:不仅仅是 set,还有自定义的 action
基本原子用 set 直接赋值就够了,但有些场景下,我们希望把一些操作逻辑封装起来,不让业务组件感知到具体的数据变化过程。jotai 的 atom() 可以传入第二个参数,这样就能定义自定义的写操作。
比如一个典型的“登录”需求:
import { atom } from 'jotai' interface User { name: string isLogin: boolean } export const userAtom = atom<User>({ name: '', isLogin: false }) // 自定义写操作:login 和 logout export const userActionsAtom = atom( (get) => get(userAtom), (get, set, action: 'login' | 'logout', payload?: string) => { if (action === 'login') { set(userAtom, { name: payload || '', isLogin: true }) } else if (action === 'logout') { set(userAtom, { name: '', isLogin: false }) } } )调用方式:
const [user, dispatch] = useAtom(userActionsAtom) dispatch('login', '张三') dispatch('logout')可能有人会问:第一参数返回 get(userAtom),第二参数接收 action,这不是又把 Redux 那套 action 的概念带回来了吗?其实完全不一样。这里只是把一个操作函数暴露给组件,组件调用时传入足够的参数即可,没有 type 常量、没有 reducer 分支表,更不需要把操作行为收敛到一个统一入口。它的本质是利用闭包把数据操作逻辑聚合到一个地方,方便复用和测试。
如果你的更新逻辑就是简单的赋值,那直接 set 就行,完全没必要套一层自定义写操作。只有当你发现同样的逻辑在多个组件中重复出现、或者操作步骤多于两步时,才值得原子化。
2.3 异步原子:处理 API 请求的天然方式
后端数据请求是前端状态管理绕不开的场景。jotai 对异步的支持是我最喜欢的一点。我们可以直接把一个返回 Promise 的写进 atom:
import { atom } from 'jotai' // 模拟一个异步请求 async function fetchUserData(id: string) { const res = await fetch(`/api/user/${id}`) return res.json() } export const userIdAtom = atom('1') export const userDataAtom = atom(async (get) => { const id = get(userIdAtom) return await fetchUserData(id) })在组件里这样使用:
import { useAtomValue } from 'jotai' import { userDataAtom } from '../atoms' function UserData() { const userData = useAtomValue(userDataAtom) // 注意:这里返回的是包裹后的状态对象,包含 data、isPending、error 三个属性 if (userData.isPending) return <div>加载中...</div> if (userData.error) return <div>出错了:{userData.error.message}</div> return <div>{userData.data.name}</div> }这里的 useAtomValue 是只读模式,它让组件只订阅 atom 的值,不触发写操作相关的重渲染,性能更好。当你只需要读取数据时,建议优先用 useAtomValue;当你只需要更新值时,用 useSetAtom;只有既读又写才用 useAtom。
异步原子还支持依赖多个原子,当依赖发生变化时,它会自动重新执行异步函数。比如 userId 一变,userDataAtom 就会自动重新请求,不需要我们手动写 effect 去联动。这一点在列表筛选、详情页切换等场景非常有用,代码量直接减半。
有一点要留意:异步原子中的 Promise 一旦 resolve,结果就会作为 data 缓存起来。后续如果依赖没有变化,读取不会重新请求。需要手动刷新数据时,可以把一个可变的“刷新标记”原子作为依赖,或者用 jotai 官方提供的 loadable 工具来处理更复杂的加载状态。
3. 实操过程与核心环节实现
3.1 环境准备与项目搭建
这一节我们用一个完整的购物车案例来走一遍实操。假设我们要做一个简单的商品列表 + 购物车页,功能要求是:可以加载远程商品列表,可以加入购物车,可以调整数量,购物车总价自动计算。
先初始化项目:
# 使用 Vite 创建 React + TypeScript 项目 npm create vite@latest jotai-demo -- --template react-ts cd jotai-demo npm install npm install jotai目录结构上我习惯这样组织 atoms:
src/ atoms/ index.ts // 汇总导出所有 atom cart.ts // 购物车相关原子 products.ts // 商品列表相关原子 components/ ProductList.tsx Cart.tsx App.tsx3.2 商品列表异步原子
products.ts 里定义一个异步原子,负责拉取商品数据。为了演示,我用一个本地 mock 函数模拟接口:
import { atom } from 'jotai' interface Product { id: string name: string price: number } // 模拟接口 function fetchProducts(): Promise<Product[]> { return new Promise((resolve) => { setTimeout(() => { resolve([ { id: '1', name: '机械键盘', price: 299 }, { id: '2', name: '人体工学椅', price: 1299 }, { id: '3', name: '显示器支架', price: 199 }, ]) }, 800) }) } export const productsAtom = atom(async (): Promise<Product[]> => { return await fetchProducts() })在 ProductList 组件中读取:
import { useAtomValue } from 'jotai' import { productsAtom } from '../atoms/products' export function ProductList() { const products = useAtomValue(productsAtom) if (products.isPending) return <div>商品加载中...</div> if (products.error) return <div>加载失败:{products.error.message}</div> return ( <ul> {products.data.map((product) => ( <li key={product.id}> <span>{product.name}</span> <span>¥{product.price}</span> <AddToCartButton productId={product.id} /> </li> ))} </ul> ) }这里的 useAtomValue 返回的是一个特殊状态对象,jotai 把 pending、error、data 三态都封装进去了,所以即使在异步加载场景,我们也不需要额外定义 isPending、error 状态,少了一堆样板代码。
3.3 购物车原子:原语化设计与数量加减
购物车的数据结构我倾向于把它设计成一个以商品 id 为 key 的记录:
import { atom } from 'jotai' import { productsAtom } from './products' export type CartItem = { productId: string; quantity: number } export type Cart = Record<string, CartItem> // 购物车初始状态:空对象 export const cartAtom = atom<Cart>({})加入购物车这种操作涉及对已有对象做更新,我用自定义写操作原子封装一层,这样所有更新逻辑都集中在一个地方:
export const addToCartAtom = atom( (get) => get(cartAtom), (get, set, productId: string, quantity: number = 1) => { const cart = get(cartAtom) const existing = cart[productId] // 如果商品已经存在,累加数量;否则新建一个条目 if (existing) { set(cartAtom, { ...cart, [productId]: { ...existing, quantity: existing.quantity + quantity }, }) } else { set(cartAtom, { ...cart, [productId]: { productId, quantity }, }) } } ) // 修改数量:delta 正数为增加,负数为减少 export const updateQuantityAtom = atom( (get) => get(cartAtom), (get, set, productId: string, delta: number) => { const cart = get(cartAtom) const existing = cart[productId] if (!existing) return const newQuantity = existing.quantity + delta if (newQuantity <= 0) { // 数量归零时直接移除商品 const nextCart = { ...cart } delete nextCart[productId] set(cartAtom, nextCart) } else { set(cartAtom, { ...cart, [productId]: { ...existing, quantity: newQuantity }, }) } } )AddToCartButton 组件里这样用:
import { useSetAtom } from 'jotai' import { addToCartAtom } from '../atoms/cart' export function AddToCartButton({ productId }: { productId: string }) { // 只需要写,不需要读,用 useSetAtom 更精准 const addToCart = useSetAtom(addToCartAtom) return <button onClick={() => addToCart(productId)}>加入购物车</button> }为什么只写操作也用 useSetAtom 而不是 useAtom?因为 useAtom 会订阅整个 atom 的值,即使我们只拿第二项 set 函数,组件也可能因为值变化而重渲染。useSetAtom 只拿到 setter,完全不会订阅数据,性能上更干净。这个细节在小 demo 里看不出来,但在商品多、购物车变化频繁的真实场景中非常明显。
3.4 派生原子:购物车数量、总价、总件数
购物车总件数和总价都是根据 cartAtom 和 productsAtom 推算出来的,根本不需要额外维护 state。这就是派生原子的用武之地:
import { atom } from 'jotai' import { cartAtom } from './cart' import { productsAtom } from './products' // 购物车总件数 export const cartCountAtom = atom((get) => { const cart = get(cartAtom) return Object.values(cart).reduce((sum, item) => sum + item.quantity, 0) }) // 购物车总价 export const cartTotalAtom = atom((get) => { const cart = get(cartAtom) const products = get(productsAtom) if (products.isPending || products.error) return 0 // 建立一个商品 id -> price 的映射 const priceMap = new Map(products.data.map((p) => [p.id, p.price])) return Object.values(cart).reduce((sum, item) => { const price = priceMap.get(item.productId) || 0 return sum + price * item.quantity }, 0) })Cart 组件展示:
import { useAtomValue } from 'jotai' import { cartAtom, updateQuantityAtom } from '../atoms/cart' import { cartCountAtom, cartTotalAtom } from '../atoms/cartSummary' export function Cart() { const cart = useAtomValue(cartAtom) const cartCount = useAtomValue(cartCountAtom) const cartTotal = useAtomValue(cartTotalAtom) const updateQuantity = useSetAtom(updateQuantityAtom) return ( <div> <h3>购物车({cartCount} 件商品)</h3> {Object.keys(cart).length === 0 && <p>购物车还是空的</p>} <ul> {Object.values(cart).map((item) => ( <li key={item.productId}> 商品 {item.productId} x {item.quantity} <button onClick={() => updateQuantity(item.productId, 1)}>+</button> <button onClick={() => updateQuantity(item.productId, -1)}>-</button> </li> ))} </ul> <p>总价:¥{cartTotal}</p> </div> ) }整个购物车案例跑下来,我们没写过任何 reducer、action、context,也没有 useEffect 去手动同步数据,所有状态都是声明式的、可追踪的。新同事看到代码也能很快理解:每个 atom 就是一个数据源,派生 atom 就是由它计算出来的展示数据,之类的逻辑一目了然。
3.5 修改 atom 默认值的几种方式
有时候我们希望某个 atom 的初始值不是一个写死的常量,需要根据运行环境变量或前端配置来生成。可以使用 atom 的初始化函数语法:
export const apiBaseUrlAtom = atom(() => { return import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000' })另一个高频场景是:完成一次操作后,把状态重置为初始值。比如“下单成功清空购物车”。比较干净的做法是先把初始状态提取出来,再定义一个 reset 操作:
const initialCart: Cart = {} export const cartAtom = atom<Cart>(initialCart) export const clearCartAtom = atom( (get) => get(cartAtom), (_get, set) => { set(cartAtom, initialCart) } )直接 set 一个 initialCart 引用也是可以的,但如果你的数据里包含日期、随机数等运行时信息,最好用函数式初始化并完整重置。
4. 常见问题与排查技巧实录
4.1 原子声明方式不对导致状态不共享
这是我见过最频繁的坑:在组件内部定义 atom。
// 错误写法示范 function BadComponent() { const localAtom = atom(0) const [count, setCount] = useAtom(localAtom) // ... }每次组件渲染,atom(0) 都会重新创建一个全新的原子,状态根本不可能跨组件共享。更隐蔽的问题是,如果一个 atom 被多个组件引用,而它被定义在某个组件的函数体里面,不同组件拿到的可能不是同一个实例,看起来像是 bug,实际上是你把原子定义错了位置。
正确做法永远是:把 atom 定义在组件外部,模块顶层。jotai 能实现全局共享靠的就是模块的幂等性——同一个模块里导出的 atom 常量始终是同一个引用。这也是我在项目里要求“所有 atom 必须放在独立的 atoms 文件夹或模块顶层”的原因。
4.2 异步原子不会重新请求数据
有朋友在项目里遇到这种情况:切换了用户 id,异步原子居然不刷新数据。大概率是依赖写错了。请看下面的错误示范:
// 错误:没有把 userId 读到 get 里,异步函数就不算依赖 const userDataAtom = atom(async () => { return await fetchUserData(userIdAtom.toString()) })正确的写法必须显式通过 get 调用目标 atom,jotai 才能把它识别为依赖关系:
const userDataAtom = atom(async (get) => { const userId = get(userIdAtom) return await fetchUserData(userId) })还有一点:当异步原子内使用 fetch 请求时,如果并发读取同一个异步 atom,jotai 会共享同一个 Promise,不会重复发送请求,这是内置的功能。如果你确实想跳过缓存强制刷新,可以在 atom 的初始化函数中管理一个内部的刷新计数,或者使用 jotai 的 loadable 工具后手动重新执行。
4.3 组件过度渲染问题
jotai 本身性能已经够好,但如果你在组件里同时 useAtom 了一个大对象原子,那对象内部任何字段变化,组件照样会重渲染。原子粒度太粗,过度渲染是躲不掉的。
我自己的做法是:大型数据优先拆分成多个小型原子。比如用户信息拆成 userProfileAtom、userSettingsAtom、userStatusAtom 三个原子,而不是一个大 userAtom。这样用户设置变了就不会导致整个个人信息区块重渲染。
如果确实不想拆,也可以通过 selector 语法创建派生原子来读取子字段:
import { atom } from 'jotai' export const userAtom = atom<User>({...}) // 只取名字字段 export const userNameAtom = atom((get) => get(userAtom).name)组件只订阅 userNameAtom,哪怕 userAtom 里其他字段反复变,只要 name 没变,组件就不会重渲染。
4.4 Provider 缺失或多 Provider 状态隔离
虽然 jotai 不包 Provider 也能用(默认全局 store),但在以下场景中,Provider 还是必须的:
- 需要同一个页面出现多个互不干扰的状态实例,比如一个页面同时展示两个相同模块,各自维护独立的购物车数据。
- 需要做状态重置、测试隔离,或者配合 HMR 开发环境处理热更新。
如果你包裹了 Provider,注意别把 Provider 放在组件内部,否则每次组件渲染 Provider 都会被重新挂载,状态全部丢失。Provider 应该像入口文件那样,稳定地挂在组件树顶层:
export default function Root() { return ( <Provider> <App /> </Provider> ) }4.5 常见报错和解决方案速查
| 现象 | 原因 | 解决方法 |
|---|---|---|
| Atom 状态在两个组件中不同步 | atom 被定义在组件函数内部 | 把 atom 提升到模块顶层,确保引用一致 |
| 异步 atom 不刷新 | 依赖的 atom 没有通过 get 显式读取 | 在异步函数里用 get(xxxAtom) 建立依赖关系 |
| 页面报错 Cannot read properties of undefined | 异步 atom 的 data 在 pending/error 时被直接访问 | 先判断 isPending、error,再访问 data |
| 更新对象中部分字段,其他字段丢失 | 直接 set 了只包含部分字段的新对象 | 使用函数式更新:set(atom, prev => ({ ...prev, ...newFields })) |
| 组件频繁重渲染 | useAtom 大对象,订阅了过多字段 | 拆分原子或用派生原子只读目标字段 |
| 页面单位切换后状态未重置 | store 生命周期管理不清晰 | 使用 Provider 隔离或手动调用 reset 操作 |
4.6 与 Redux 和 Zustand 的选型对比
每当聊起新状态管理方案,总有人要问:那我还用不用 Redux?还要不要学 Zustand?我的建议始终是,看场景。
| 维度 | jotai | Zustand | Redux Toolkit |
|---|---|---|---|
| 学习曲线 | 极低,API 只有 atom 和 useAtom | 低,类 hooks 用法 | 较高,action/reducer/selector 概念多 |
| 状态模型 | 原子化拆分,粒度自由 | 单一 store,按 slice 拆分 | 单一 store,按 reducer 拆分 |
| 派生状态 | 原生支持派生原子 | 手动计算或配合 selector | 通过 createSelector 实现 |
| 异步处理 | 原生异步原子 | 支持异步 action | 通过 createAsyncThunk 等中间件 |
| 调试体验 | 需要额外的 devtools 插件 | 有 devtools 中间件 | 调试工具最成熟 |
| 适合项目 | 中小型、组件共享多、追求快速开发 | 中小型、全局 store 清晰 | 大型、需要严格架构约束和团队规范 |
Redux 的强项在于严格的数据流约束和成熟的 devtools 生态,适合大型团队协作中需要保持统一架构的场景。Zustand 则适合习惯“一个 store 管所有”的开发者,API 轻量但状态模型仍然是集中式的。jotai 则更适合把注意力放在“组件 + 就近状态”上的项目,尤其是从 useState 渐进迁移到全局共享的场景,上手成本几乎为零。
说句实在话,这三个方案选哪个都不会错,重要的是团队能不能理解和驾驭它的约束。如果你是被 Redux 样板代码磨得没脾气、又在 Context 全量渲染里痛过的人,jotai 很可能就是那个让你重新觉得“状态管理原来可以这么简单”的方案。