
在 React 生态里状态管理一直是个“老生常谈又永远在折腾”的话题。从早期的 Redux、MobX到后来的 Zustand、Valtio再到今天要聊的 jotai几乎每一年都会冒出一个新方案。很多同事问我都 2024 年了怎么还在搞状态管理我的答案是因为业务复杂度在涨而 React 本身对“跨组件共享状态”这事儿一直没给出一把“随手能用、用着不痛”的钥匙。Context 能解决一部分问题但性能瓶颈和样板代码很快就让人头大。直到我用了 jotai才感觉状态管理从“设计模式”变成了“写代码的本能”。jotai 这个名字来自日语“状態じょうたい”翻译过来就是“状态”。它的核心思想极其简单原子化的状态。你不需要再创建一个集中式的 store不需要写 action、reducer、dispatch不需要封装各种 selector。你就定义一个个独立的 atom然后像 useState 一样去读它、写它。它基于 React 18 的 useSyncExternalStore 构建底层做了极致的依赖追踪和渲染优化。这篇文章就基于我最近几个生产项目的实际使用经验把这套“原子化状态管理”的思路、原理、坑和最佳实践一次讲透。不管你是刚从 Redux 迁移过来、被 Context 重复渲染折磨得焦头烂额还是刚接触 React 想找一套低学习成本的状态方案这篇文章都适合你。我会从设计思路、核心 API、常用模式到排查技巧一层层展开尽量还原我实际写码时的真实判断过程。1. 为什么是 jotai从痛点反推出来的状态管理方案1.1 Context 的“伪共享”困境先说说我之前用 Context 遇到的真实问题。项目里有个用户信息面板登录后需要展示头像、昵称、等级、会员到期时间。按照常规思路我把它放在一个UserProvider里然后在子组件里useContext(UserContext)拿数据。看起来简单但一旦用户信息更新比如修改昵称所有消费这个 Context 的组件都会重新渲染哪怕它们只关心其中某个字段。为了优化我尝试把 Context 切碎搞了UserProfileContext和UserStatusContext但切着切着就发现代码结构被 Context 绑架了——每个模块都要包一层 Provider组件树越来越深维护成本越来越高。而 jotai 的做法是你在任何地方const [user] useAtom(userAtom)它只订阅你实际用到的那个 atom没用到的组件压根不会触发渲染。1.2 Redux 的“大力士”问题Redux 本身没有问题它有严格的数据流、可预测的状态容器、强大的 DevTools。但问题在于很多业务场景根本不需要“大力士”。一个简单的中后台页面为了一个弹窗开关状态我得写 action type、action creator、reducer、dispatch四五个文件动辄上百行产品经理在旁边催排期的时候这种效率真的扛不住。jotai 的原子化设计避开了这套“仪式感”。它的状态就是一个普通的 JS 对象或基本值// 就这么简单一个 atom 就是一段状态 const countAtom atom(0)想读useAtom(countAtom)。想改setCount(count 1)。没有 action没有 reducer没有 dispatch。如果你只是想要一个跨组件共享的 useStatejotai 就是那一把“不需要思考就能用”的钥匙。1.3 Zustand 的单 store 模型对比有人会问Zustand 不也很简单吗确实Zustand 也很轻量但它仍然是“单 store”模型所有状态都挂在一个全局 store 里。虽然它也可以创建多个 store但默认的使用习惯还是会趋向于“把所有业务状态塞进一个大对象”时间一长store 会越来越大组件之间的耦合也会变深。jotai 的原子化是真正的“自底向上”没有全局 store每个 atom 独立存在组件需要什么就组合什么。维度ReduxZustandContextjotai学习成本高中低极低样板代码多少少极少渲染优化需手写 selector可选 selector默认全量重渲染自动原子级追踪Provider需要不需要需要可选异步支持中间件支持手动原生支持代码量同功能150 行60 行80 行20 行这个对比不是说要分谁高谁低而是提醒一句技术选型一定要跟业务场景匹配。大型复杂应用用 Redux 依然合理轻量、频繁迭代、团队人数不多的项目jotai 的性价比确实高。2. 原子化状态的核心思路从 useAtom 到自动依赖追踪2.1 先理解 atom 的两种形态jotai 里最常用的就两个 APIatom()和useAtom()。atom()是定义状态useAtom()是在组件里消费状态。但atom()的能力比想象中强它可以分为四种常见形态原始值 atomconst countAtom atom(0)这是最基础的状态容器可以是一个数字、字符串、对象、数组。派生 atomconst countAtom atom(0) const doubleCountAtom atom((get) get(countAtom) * 2)派生 atom 不会自己保存状态而是通过get函数读取其他 atom 的值然后计算出一个新值。只要被依赖的 atom 变化派生值会自动更新。异步 atomconst userAtom atom(async () { const res await fetch(/api/user) return res.json() })异步 atom 是 jotai 的一大杀手锏。它原生支持 Promise配合 Suspense 或内置的loadable、unwrap工具可以很优雅地处理加载态和错误态完全不需要手写 useEffect loading error 三段式。只写 atomconst addCountAtom atom(null, (get, set, payload: number) { set(countAtom, get(countAtom) payload) })只写 atom 不保存状态只定义“如何改变状态”。它像是一个函数封装适合做复杂的状态更新逻辑比如联动更新多个 atom。2.2 useAtom 的返回值为什么像 useStateuseAtom的返回值和useState几乎一样一个是当前值一个是更新函数。这也是 jotai 学习成本极低的原因之一。如果你已经会写 useState那你已经会写 jotai 了。function Counter() { const [count, setCount] useAtom(countAtom) return ( div span{count}/span button onClick{() setCount(c c 1)}1/button /div ) }注意这里setCount既可以直接传新值也可以传一个函数和 useState 的 setter 行为一致。2.3 自动依赖追踪的底层逻辑为什么 jotai 能做到“用不到就不渲染”这得从它的订阅机制说起。jotai 在底层维护了一张 atom 依赖图。当一个组件通过useAtom读取某个 atom 的值时它会向这个 atom 注册一个订阅回调。当这个 atom 的值发生变化时jotai 会根据依赖图找到所有直接或间接依赖它的组件只通知这些组件重新渲染。举个例子const nameAtom atom(张三) const ageAtom atom(18) const profileAtom atom((get) ({ name: get(nameAtom), age: get(ageAtom), })) function NameDisplay() { const [name] useAtom(nameAtom) return div{name}/div } function ProfileDisplay() { const [profile] useAtom(profileAtom) return div{profile.name} / {profile.age}/div }当ageAtom更新时NameDisplay不会重新渲染只有ProfileDisplay会更新因为ageAtom存在于它的依赖链里。这个粒度控制是 Context 做不到的。2.4 Provider 是可选的吗默认情况下jotai 可以在不包 Provider 的情况下直接用——它内部有一个默认的全局 store。这对大多数应用来说是足够的。当你需要多实例隔离比如同一个组件在不同区域展示不同状态或者需要给某个子树单独指定状态作用域时再用Provider包裹。import { Provider } from jotai function App() { return ( Provider UserPanel / /Provider ) }我个人的习惯是没遇到隔离需求就裸用遇到需求了再加 Provider这两种方式是无缝切换的代码不需要额外调整。3. 常见应用场景实操登录态、主题切换与列表页数据联动3.1 登录态管理的完整实现用户登录态几乎是每个前端项目都要处理的状态。用 jotai 写起来比 localStorage Context 的组合舒服多了。我先定义用户信息 atom// store/user.ts import { atom } from jotai export interface UserInfo { id: string name: string email: string avatar: string token: string } // 初始值从 localStorage 恢复实现“刷新不丢登录态” const storedUser typeof window ! undefined ? localStorage.getItem(jotai_user) : null export const userAtom atomUserInfo | null( storedUser ? JSON.parse(storedUser) : null ) // 写一个专门用于更新用户信息的函数 export const setUserAtom atom( null, (get, set, user: UserInfo | null) { set(userAtom, user) if (user) { localStorage.setItem(jotai_user, JSON.stringify(user)) } else { localStorage.removeItem(jotai_user) } } )这个做法很实用。setUserAtom是一个“只写 atom”它封装了“更新内存状态 同步 localStorage”的逻辑组件里只需要调用setSetUser(user)或setSetUser(null)即可不需要每个组件都关心持久化细节。在组件中使用function LoginButton() { const [, login] useAtom(setUserAtom) const handleLogin async () { const res await fakeLoginApi() // 直接写入用户信息同时写入 localStorage login(res.data.user) } return button onClick{handleLogin}登录/button }很多项目还会遇到“用户信息更新后多处 UI 同步刷新”的需求。比如用户在设置页改了昵称导航栏的“你好XXX”也要实时变化。用 jotai 就是一行代码的事——因为导航栏的useAtom(userAtom)和设置页的setUserAtom指向同一个 atom数据自然会联动。3.2 主题切换功能的快速落地主题切换是典型的“全局 UI 状态”场景。用 jotai 实现不仅代码少还能和 CSS 变量无缝配合。我先定义一个主题类型和对应的样式变量// store/theme.ts import { atom } from jotai export type ThemeMode light | dark const storedTheme typeof window ! undefined ? (localStorage.getItem(jotai_theme) as ThemeMode) || light : light export const themeAtom atomThemeMode(storedTheme)然后写一个主题更新函数负责切换根节点的>export const toggleThemeAtom atom( null, (get, set) { const next get(themeAtom) light ? dark : light set(themeAtom, next) localStorage.setItem(jotai_theme, next) if (typeof document ! undefined) { document.documentElement.setAttribute(data-theme, next) } } )在组件中只需要一个按钮function ThemeToggle() { const [theme] useAtom(themeAtom) const [, toggle] useAtom(toggleThemeAtom) return ( button onClick{toggle} 当前模式{theme}点击切换 /button ) }因为toggleThemeAtom内部同时处理了 localStorage 和 DOM 属性组件不需要关心这些细节。这个模式很典型——把副作用收敛到 atom 层组件保持纯粹。3.3 列表页的多级联动筛选再分享一个更复杂的实际案例一个商品列表页有三个筛选条件分类、价格区间、排序方式筛选结束后要请求后端接口并将返回的数据展示出来。传统写法要管理三个筛选条件外加一个 loading 状态用 jotai 可以组织得清清楚楚。// store/filter.ts import { atom } from jotai export const categoryAtom atomstring(all) export const priceRangeAtom atom[number, number]([0, 10000]) export const sortByAtom atomdefault | price-asc | price-desc(default) // 派生 atom把筛选条件组合成一个查询参数对象 export const filterParamsAtom atom((get) { return { category: get(categoryAtom), minPrice: get(priceRangeAtom)[0], maxPrice: get(priceRangeAtom)[1], sortBy: get(sortByAtom), } }) // 异步 atom根据筛选条件请求数据 export const productListAtom atom(async (get) { const params get(filterParamsAtom) const res await fetchProducts(params) return res.list })列表组件里只需要一个 hook 就能拿到所有数据function ProductList() { const [products] useAtom(productListAtom) return ( div {products.map((p) ( ProductCard key{p.id} product{p} / ))} /div ) }筛选组件各自修改对应的 atom 即可function CategoryFilter() { const [category, setCategory] useAtom(categoryAtom) return ( select value{category} onChange{(e) setCategory(e.target.value)} option valueall全部分类/option option valuedigital数码/option option valueclothing服饰/option /select ) }这里要注意一个细节由于productListAtom是异步 atomuseAtom拿到的products可能是一个 Promise 而不是真实数据。这时候有两个选择一是配合 Suspense 使用组件外层包一层Suspense二是用loadable工具拿到显式的状态。我在生产环境里更推荐后者因为列表页通常需要在数据加载时展示骨架屏或 loading 动画loadable给了更细的控制力。import { loadable } from jotai/utils const productListLoadable loadable(productListAtom) function ProductList() { const [products] useAtom(productListLoadable) if (products.state loading) { return Skeleton / } if (products.state hasError) { return ErrorView message{products.error.message} / } return ProductGrid data{products.data} / }loadable让异步 atom 从“隐式 loading”变成了“显式状态”这在实际开发中比 Suspense 更可控。3.4 多个 atom 组合成场景专属状态再补充一个实战技巧。当业务复杂到一定程度你会发现单个 atom 不够用需要组合多个 atom 形成“一个业务场景的状态片段”。jotai 允许在一个 atom 里get其他 atom也可以在组件里同时 use 多个 atom这比 Redux 的组合 reducer 方案直观得多。function Dashboard() { const [user] useAtom(userAtom) const [theme] useAtom(themeAtom) const [products] useAtom(productListLoadable) // 业务逻辑直接在这里组合 const showWelcome user theme light return ( div {showWelcome WelcomeBanner userName{user.name} /} ProductsTable data{products.data} / /div ) }组合是原子的天赋。它不会像 Redux 那样要求你把所有状态先定义好再组合到 store 里而是让你按需组合、随用随建这非常契合敏捷开发的工作方式。4. 用 jotai 的常见问题与排查技巧实录4.1 async atom 重复请求问题在实际项目中我最常遇到的问题就是异步 atom 被重复请求。比如一个productListAtom组件 A 和组件 B 都用了它如果 A 先触发了请求B 再挂载jotai 会不会再请求一次默认行为是如果这个 atom 已经被订阅且值未过期新的订阅者会直接拿到当前值不会触发重复请求。但如果你在多个地方同时调用useAtom(productListAtom)确实可能会在首次挂载时并发发起多个请求。我的解决方案是把异步请求的缓存逻辑收敛到 atom 初始化阶段先判断是否已经请求过再决定是否重新请求。或者更简单粗暴一点用一个模块级变量做标记let isProductFetching false let productFetched false export const productListAtom atom(async (get) { if (productFetched) { const cached await getCachedProducts() return cached } if (!isProductFetching) { isProductFetching true const res await fetchProducts() productFetched true return res.list } // 如果正在请求中返回一个空数组或者等待 return [] })这段代码我实际用下来能解决并发请求问题。如果你有更好的方案比如用atomWithQuery这类封装也可以但核心思路是一致的异步 atom 不是数据请求缓存库频繁变化的接口数据建议结合 SWR 或 TanStack Query 使用jotai 更适合管理页面级状态。4.2 派生 atom 导致的无限循环派生 atom 如果写法不当很容易出现死循环。最常见的坑是在派生 atom 里同时 get 和 set 自己// 错误示范 const countAtom atom(0) const doubleCountAtom atom( (get) get(countAtom) * 2, (get, set) { set(countAtom, get(countAtom) 1) set(doubleCountAtom, get(doubleCountAtom)) // 死循环 } )这种情况下doubleCountAtom的 setter 里 set 了自己的值会触发它重新计算计算完又触发 setter没完没了。解决方案很简单不要在派生 atom 的 setter 里写更新自己逻辑把联动逻辑拆到独立的只写 atom 里。4.3 调试工具Redux DevTools 也能用jotai 官方提供了jotai-devtools插件可以接入 Redux DevTools。我之前调试半天找不到 bug装上之后一下就看出来了。接入方式也不复杂import { atomWithDevtools } from jotai/devtools export const userAtom atomWithDevtoolsUserInfo | null(null, user)装上后在 Redux DevTools 里能看到每个 atom 的变化轨迹支持时间旅行调试。不过要注意一点生产环境不要启用 devtools最好通过环境变量控制。4.4 在 React 之外使用 jotai还有一个会被忽略的痛点——有时候业务状态需要在非组件文件中使用比如 axios 拦截器里读取 token、工具函数里判断登录状态。jotai 提供了store对象可以脱离 React 直接操作import { store } from jotai // 在拦截器里直接读状态 const token store.get(userAtom)?.token // 直接写状态 store.set(userAtom, null)这个特性是 Context 完全给不了的。Context 只能在组件树内作用而 jotai 的默认 store 是全局的随处可用。4.5 性能优化避免在组件里派生大对象虽然 jotai 有原子级依赖追踪但如果你在组件里写类似const [user] useAtom(userAtom)然后再const displayName user.name user.level 那么只要 user 对象变化组件必然重新渲染。这在多数场景下没问题但如果 user 对象很大且更新频繁建议把派生逻辑抽成派生 atomconst displayNameAtom atom((get) { const user get(userAtom) return ${user.name}Lv.${user.level} }) function UserGreeting() { const [displayName] useAtom(displayNameAtom) return span{displayName}/span }这样UserGreeting只订阅displayNameAtom它只关心 user 变化后计算出的字符串是否改变。如果别的地方更新了 user 的 email 字段而这个字段不影响displayName的计算结果jotai 的依赖追踪会自动跳过这次组件渲染。这个优化在数据量大、更新频繁的场景下收益非常明显。4.6 与 TypeScript 的配合让状态类型安全jotai 对 TypeScript 的支持非常好。因为 atom 本身就是一种类型化的状态容器只要你定义了泛型后续所有useAtom、store.set、派生 atom 都会自动获得类型推导。interface CartLine { productId: string name: string price: number quantity: number } const cartAtom atomCartLine[]([]) // 这里的 state 会被自动推导为 CartLine[] function Cart() { const [cart] useAtom(cartAtom) return ( ul {cart.map((line) ( li key{line.productId} {line.name} × {line.quantity} ¥{line.price * line.quantity} /li ))} /ul ) }我见过很多团队因为状态管理库类型支持差被迫写一堆any最后代码越写越糊。jotai 的类型推导基本是零成本享受哪怕不做额外封装也能拿到完整的类型安全。5. jotai 踩坑心得与设计理念复盘5.1 什么时候该用 jotai什么时候不该用任何技术方案都有边界jotai 也不例外。基于我几个实际项目的经验适合用 jotai 的典型场景中后台管理系统页面多、状态分散、频繁迭代代码量要精简微前端架构不同子应用之间需要状态隔离jotai 的 Provider 机制很好用单页应用里的局部跨组件共享状态比如购物车、步骤条、多渠道联动团队新成员多、前端基础层次不齐jotai 上手成本最低几乎不用培训不适合用 jotai 的场景需要严格的状态可回溯和审计如金融交易系统Redux 的时间旅行和严格数据流更合适全局状态的读写频繁且数据模型极其复杂可能需要更结构化的方案项目已经重度使用 Redux 且维护良好没必要为了炫技迁移5.2 团队协作中的状态约定虽然 jotai 用起来很自由但这就意味着团队必须有一套自己的约定否则容易变成“状态满天飞”。我在项目里总结了几条规则分享出来参考命名规范atom 统一以Atom结尾比如userAtom、cartAtom方便一眼识别存放位置同模块的 atom 放在同一个文件里比如store/user.ts、store/cart.ts不要到处散落定义只读/只写分离如果一个 atom 只应该被特定函数修改就把修改逻辑封装成只写 atom避免组件里直接set异步 atom 控制好副作用不要把所有接口请求都塞进 atomjotai 不是数据请求库该用 SWR 的用 SWR这四条规则看起来简单但落地后代码质量提升非常明显。特别是“只写 atom”的约定能帮团队避免很多“状态哪里被改了”的排查难题。5.3 为什么我觉得 atom 方案是未来的方向从 React 18 开始官方在倡导 Server Components 和更服务端的思维模式前端状态管理会逐渐回归到“只管理必要状态”的形态。jotai 的“原子化”设计天然契合这种趋势——它不和 React 的渲染机制对抗而是在 React 的数据流之上做更细粒度的状态追踪。它不是要替代 Redux而是提供另一条更符合直觉的路。我在生产环境用 jotai 一年多了最大的感受是“状态管理这个事儿终于变简单了”。以前写 Redux 的时候我总是在想“这个状态该放 reducer 还是 component state”现在我用 jotai这个权衡基本不存在——需要共享就抽成 atom不需要共享就写在组件里。这种自由和轻量带来的不只是效率提升还有写代码时的愉悦感。如果你也在被状态管理折磨不妨在下个小项目里试试 jotai可能你也会和我一样第一周之后就回不去了。