Jotai:把 React state 拆成原子(比 Zustand 更细粒度)

起因

复杂表单 / 多级嵌套组件共享 state 时,Zustand 的"一个 store 多字段"
还是会让"用 fieldA 的组件" 因为 fieldB 改而 re-render(除非每字段
单独 selector)。

Jotai 思路完全不同:每个状态是一个独立的 atom。组件只订阅它真用到
的 atom,atom 变才重渲。天然细粒度。

解决方案

npm i jotai

第一个 atom

import { atom, useAtom } from 'jotai'

const countAtom = atom(0)

function Counter() {
  const [count, setCount] = useAtom(countAtom)
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

function Display() {
  const [count] = useAtom(countAtom)
  return <span>{count}</span>
}

useAtomuseState 几乎一样的 API。
多个组件共享同一 atom 时自动同步。

不需要 Provider(默认全局):

function App() {
  return (
    <>
      <Counter />
      <Display />
    </>
  )
}

两个组件用同一 atom,互相同步。

Derived atom(计算属性)

const countAtom = atom(0)
const doubledAtom = atom((get) => get(countAtom) * 2)

function Doubled() {
  const [doubled] = useAtom(doubledAtom)
  return <span>{doubled}</span>
}

doubledAtom 自动跟 countAtom 关联:count 变 → doubled 重算 →
订阅 doubled 的组件重渲。

Async atom

const userIdAtom = atom('alice')
const userAtom = atom(async (get) => {
  const id = get(userIdAtom)
  const r = await fetch(`/api/users/${id}`)
  return r.json()
})

function UserCard() {
  const [user] = useAtom(userAtom)
  return <div>{user.name}</div>
}

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserCard />
    </Suspense>
  )
}

异步 atom 自动用 React Suspense。改 userIdAtom 触发 refetch。

Atom family(动态创建 atoms)

每个 user id 一个 atom:

import { atomFamily } from 'jotai/utils'

const userAtomFamily = atomFamily((id: string) =>
  atom(async () => fetch(`/api/users/${id}`).then(r => r.json()))
)

function UserCard({ id }: { id: string }) {
  const [user] = useAtom(userAtomFamily(id))
  return <div>{user.name}</div>
}

每个 id 独立 atom;不同 UserCard 不会互相影响。

写组合:写一个 atom 触发多个修改

const firstNameAtom = atom('')
const lastNameAtom = atom('')

const updateNameAtom = atom(
  null,    // read function(这里没读)
  (get, set, fullName: string) => {
    const [f, l] = fullName.split(' ')
    set(firstNameAtom, f)
    set(lastNameAtom, l)
  }
)

function Form() {
  const [, setName] = useAtom(updateNameAtom)
  return <input onChange={e => setName(e.target.value)} />
}

action / mutation 风格。

持久化

import { atomWithStorage } from 'jotai/utils'

const themeAtom = atomWithStorage('theme', 'light')

function ThemeToggle() {
  const [theme, setTheme] = useAtom(themeAtom)
  return <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
    {theme}
  </button>
}

自动 localStorage。

跟 Zustand 比

Jotai Zustand
粒度 atom(极细) store(粗,需 selector)
API 风格 函数式 hook mutate / function
学习曲线 中(atom 思维转换)
大组件树 优秀(自动只更新订阅者) 需精确 selector
异步 一等公民 + Suspense 手写
复杂派生 自然 需 useMemo 等
Devtools

适合 Jotai 的场景:

  • 复杂表单(每字段独立 atom)
  • 大量派生状态
  • 需要 Suspense 异步

适合 Zustand:

  • 简单全局 store(用户 auth / theme)
  • 倾向 imperative API

实战:表单 with field-level state

import { atom, useAtom } from 'jotai'

const emailAtom = atom('')
const emailErrorAtom = atom((get) => {
  const e = get(emailAtom)
  if (!e) return null
  if (!/@/.test(e)) return '邮箱格式错'
  return null
})

const passwordAtom = atom('')
const passwordErrorAtom = atom((get) => {
  const p = get(passwordAtom)
  if (p.length < 8) return '密码至少 8 位'
  return null
})

const canSubmitAtom = atom((get) =>
  get(emailErrorAtom) === null && get(passwordErrorAtom) === null
  && get(emailAtom) && get(passwordAtom)
)

function EmailField() {
  const [email, setEmail] = useAtom(emailAtom)
  const [error] = useAtom(emailErrorAtom)
  return (
    <div>
      <input value={email} onChange={e => setEmail(e.target.value)} />
      {error && <p>{error}</p>}
    </div>
  )
}

// PasswordField 类似

function SubmitButton() {
  const [canSubmit] = useAtom(canSubmitAtom)
  return <button disabled={!canSubmit}>提交</button>
}

每个字段独立 atom + 独立 error atom + 派生 canSubmit。
只有"我用到的" 重渲。

对比 react-hook-form:

  • RHF 更成熟 + 更多功能(registration / validation chain)
  • Jotai 概念上更轻 + 不限于表单

复杂表单仍推荐 RHF;中等表单 + 跨组件共享 Jotai 更灵活。

与 React Query 集成

import { atomWithQuery } from 'jotai-tanstack-query'

const userAtom = atomWithQuery((get) => ({
  queryKey: ['user', get(userIdAtom)],
  queryFn: async ({ queryKey: [, id] }) =>
    fetch(`/api/users/${id}`).then(r => r.json()),
}))

function UserCard() {
  const [{ data, isPending }] = useAtom(userAtom)
  if (isPending) return <Spinner />
  return <div>{data.name}</div>
}

React Query 的 cache + Jotai 的 atom 组合 = 服务端状态 + 客户端
state 一致管理。

调试:Jotai Devtools

import { useAtomDevtools } from 'jotai-devtools'

function DebugAll() {
  useAtomDevtools(countAtom, { name: 'count' })
  useAtomDevtools(userAtom, { name: 'user' })
  return null
}

Redux DevTools 显示所有 atom 变化时间线 + 当前值。

性能注意

Jotai 极细粒度 = 每个 atom 一份 React subscription。
几千个 atom + 大量更新 时 React scheduler 压力大。

千级 atom 场景考虑:

  • atomFamily 而非手写百份 atom
  • 把不需要响应的数据放普通对象 / Map
  • 必要时用 Jotai 内部 store 做 batched update

何时不用 Jotai

  • 全局只有几个简单 state(auth + theme)→ Zustand 足够
  • 已经深度用 Redux Toolkit → 切换成本大
  • 团队不熟函数式 → 学习曲线一道槛

踩过的坑

  1. atom 在组件外创建:放函数体内每次 render 新 atom → state 重置。
    atom 一定在 module top-level 创建。

  2. 派生 atom 依赖循环:A 依赖 B,B 依赖 A → 无限循环 stack overflow。
    设计时检查依赖图无环。

  3. async atom + Suspense:默认 fetch 失败抛 ErrorBoundary。
    不想用 Suspense 写 loadable(asyncAtom) 退化到 loading state pattern。

  4. atom 大对象:整个对象一变所有订阅者重渲。改为多 atom 细分 +
    selector 派生。

  5. SSR / Next.js:Jotai SSR 需要 Provider 隔离 request-level
    state,不能全局。注意配 hydration。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。