jotai原子化状态管理:告别Context渲染风暴与Redux样板代码
2026/9/24 20:41:20 网站建设 项目流程

做过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.tsx

3.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?我的建议始终是,看场景。

维度jotaiZustandRedux 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 很可能就是那个让你重新觉得“状态管理原来可以这么简单”的方案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询