React 内置了状态管理:useState + useContext。

为什么还需要 Zustand、Jotai、Valtio?

答案不在"它们更好用",而在 React 默认的更新传播模型有它的边界


一、问题:React 的更新传播

1.1 setState 的传播范围

function App() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <Header />
      <Counter count={count} setCount={setCount} />
      <HeavyList /> {/* 和 count 无关,但会重新渲染 */}
    </div>
  )
}

count 变化 → App 重新渲染 → Header、Counter、HeavyList 都重新渲染。

HeavyList 和 count 完全无关,但还是重新渲染了。

1.2 为什么 React 这样设计?

因为 React 的核心模型是:

UI = f(state)

state 变了 → 调用 f → 得到新的 UI 描述。

React 不知道"哪些组件依赖这个 state"。它只知道"state 变了,调用根组件函数,得到新的 JSX 树"。

这是 Description 模型的代价:React 不追踪依赖,只重新计算描述。

1.3 Vue 的对比

<template>
  <div>
    <Header />
    <Counter :count="count" @increment="count++" />
    <HeavyList />
    <!-- count 变了不会重新渲染 -->
  </div>
</template>

Vue 的响应式系统知道"只有 Counter 依赖 count"。count 变了 → 只有 Counter 重新渲染。

Vue 走的是 Dependency Tracking 模型:追踪谁依赖谁,精确更新。


二、React 的解法:bailout

React 不是完全没有优化。它有 bailout 机制:

function Counter({ count, onClick }) {
  return <button onClick={onClick}>{count}</button>
}

function HeavyList() {
  // 这个组件很重
  return <div>{/* 渲染 1000 个 item */}</div>
}

function App() {
  const [count, setCount] = useState(0)

  return (
    <div>
      <Counter count={count} onClick={() => setCount((c) => c + 1)} />
      <HeavyList />
    </div>
  )
}

App 重新渲染时:

  1. Counter 重新渲染(因为 count prop 变了)
  2. HeavyList 重新渲染(因为 App render 了)
  3. HeavyList 返回的 JSX 和上次一样 → bailout → DOM 不更新

bailout 避免了不必要的 DOM 操作,但组件函数还是执行了。

如果 HeavyList 的渲染函数本身就很重(比如大量计算),bailout 帮不了你。


三、memo:组件边界 bailout

const HeavyList = memo(function HeavyList() {
  console.log('HeavyList render')
  return <div>{/* 渲染 1000 个 item */}</div>
})

memo 让 HeavyList 连函数都不执行

App render

HeavyList: props 没变(=== 引用相等)

bailout → 不执行函数 → 不进入 reconcile

但 memo 有一个前提:props 的引用要稳定

// ❌ 每次 App render 都创建新对象,memo 失效
<HeavyList style={{ color: 'red' }} />

// ✅ useMemo 保持引用
const style = useMemo(() => ({ color: 'red' }), [])
<HeavyList style={style} />

React 的性能优化本质上只有一件事:让 memoizedProps === pendingProps 成立。


四、Context 的边界

Context 是 React 内置的"跨组件传递状态"方案:

const ThemeContext = createContext('light')

function App() {
  const [theme, setTheme] = useState('light')

  return (
    <ThemeContext.Provider value={theme}>
      <Layout />
    </ThemeContext.Provider>
  )
}

function Button() {
  const theme = useContext(ThemeContext)
  return <button className={theme}>Click</button>
}

4.1 Context 的更新传播

function App() {
  const [theme, setTheme] = useState('light')
  const [count, setCount] = useState(0)

  return (
    <ThemeContext.Provider value={theme}>
      <CounterContext.Provider value={count}>
        <Layout />
      </CounterContext.Provider>
    </ThemeContext.Provider>
  )
}

theme 变化 → ThemeContext.Provider 的 value 变了 → 所有 useContext(ThemeContext) 的组件重新渲染。

count 变化 → CounterContext.Provider 的 value 变了 → 所有 useContext(CounterContext) 的组件重新渲染。

Context 的粒度是 Provider 级别的,不是 value 内部的属性级别。

4.2 Context 的问题

const UserContext = createContext({ name: '', email: '', avatar: '' })

function App() {
  const [user, setUser] = useState({ name: 'Alice', email: 'alice@test.com', avatar: '...' })

  return (
    <UserContext.Provider value={user}>
      <Layout />
    </UserContext.Provider>
  )
}

function UserName() {
  const { name } = useContext(UserContext)
  return <div>{name}</div>
}

function UserAvatar() {
  const { avatar } = useContext(UserContext)
  return <img src={avatar} />
}

avatar 变了 → 整个 user 对象引用变了 → UserName 和 UserAvatar 都重新渲染。

UserName 只依赖 name,但 avatar 变了它也重新渲染了。

Context 无法做到"只订阅对象的某个属性"。

4.3 拆分 Context

const UserNameContext = createContext('')
const UserAvatarContext = createContext('')

function App() {
  const [user, setUser] = useState(...)

  return (
    <UserNameContext.Provider value={user.name}>
      <UserAvatarContext.Provider value={user.avatar}>
        <Layout />
      </UserAvatarContext.Provider>
    </UserNameContext.Provider>
  )
}

可以解决,但嵌套太多,维护成本高。


五、External Store:useSyncExternalStore

当 Context 不够用时,可以把状态放到 React 树外部。

5.1 外部 store 的基本模式

// store.js
let state = { count: 0, theme: 'light' }
const listeners = new Set()

function getState() {
  return state
}

function setState(newState) {
  state = { ...state, ...newState }
  listeners.forEach((l) => l())
}

function subscribe(listener) {
  listeners.add(listener)
  return () => listeners.delete(listener)
}

5.2 useSyncExternalStore

React 18 提供了 useSyncExternalStore 来正确接入外部 store:

import { useSyncExternalStore } from 'react'

function Counter() {
  const count = useSyncExternalStore(
    subscribe,        // 订阅函数
    () => state.count // 获取快照(客户端)
    () => state.count // 获取快照(SSR,可选)
  )

  return <div>{count}</div>
}

为什么需要 useSyncExternalStore 而不是直接用 useEffect + useState?

// ❌ 手动实现,有撕裂风险
function Counter() {
  const [count, setCount] = useState(state.count)

  useEffect(() => {
    return subscribe(() => {
      setCount(state.count)
    })
  }, [])

  return <div>{count}</div>
}

在并发模式下,这个实现可能导致 Tearing(撕裂):同一个渲染周期内,不同组件读到的 store 值不一致。

useSyncExternalStore 保证了:

  1. 读取快照是同步的
  2. 所有组件看到一致的值
  3. SSR 时使用 server snapshot

5.3 selector 模式

function Counter() {
  const count = useSyncExternalStore(
    subscribe,
    () => state.count, // selector:只取 count
  )

  return <div>{count}</div>
}

selector 让你只订阅 store 的一部分。count 变了才触发重新渲染,theme 变了不影响。


六、Zustand:最小化的 external store

Zustand 是对 useSyncExternalStore 的封装,提供了更简洁的 API:

import { create } from 'zustand'

const useStore = create((set) => ({
  count: 0,
  theme: 'light',
  increment: () => set((state) => ({ count: state.count + 1 })),
  setTheme: (theme) => set({ theme }),
}))

function Counter() {
  const count = useStore((state) => state.count)
  const increment = useStore((state) => state.increment)

  return <button onClick={increment}>{count}</button>
}

6.1 selector 自动优化

// 只有 count 变化时,这个组件才重新渲染
const count = useStore((state) => state.count)

// 只有 theme 变化时,这个组件才重新渲染
const theme = useStore((state) => state.theme)

Zustand 内部用 Object.is 比较 selector 返回值。值没变就不触发更新。

6.2 与 Context 的对比

维度ContextZustand
订阅粒度Provider 级别selector 级别
更新传播所有 consumer只有匹配的 selector
性能优化需要拆分 Context自动 selector 优化
代码量嵌套 Providercreate + useStore
SSR原生支持需要额外配置

6.3 什么时候用 Zustand

  • 全局状态:用户信息、主题、语言
  • 跨组件共享:购物车、通知列表
  • 频繁更新:鼠标位置、滚动位置
  • Context 性能不够:Provider consumer 太多,更新传播太广

七、Jotai:原子化状态

Jotai 走了另一条路:把状态拆成 atom,依赖自动追踪

import { atom, useAtom } from 'jotai'

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

function Counter() {
  const [count, setCount] = useAtom(countAtom)
  const doubled = useAtom(doubledAtom)[0]

  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>{count}</button>
      <span>× 2 = {doubled}</span>
    </div>
  )
}

7.1 atom 的依赖追踪

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

function Doubled() {
  const doubled = useAtom(doubledAtom)[0]
  return <div>{doubled}</div> // count 变了才重新渲染
}

function Tripled() {
  const tripled = useAtom(tripledAtom)[0]
  return <div>{tripled}</div> // count 变了才重新渲染
}

Jotai 知道"哪些 atom 依赖哪些 atom"。countAtom 变了 → 只有依赖它的 doubledAtom 和 tripledAtom 触发更新。

7.2 Jotai vs Zustand

维度ZustandJotai
模型单一 store + selector多个 atom + 自动依赖追踪
适合场景中等复杂度的状态复杂的、相互依赖的状态
性能优化手动 selector自动依赖追踪
学习曲线中(atom 概念)

7.3 什么时候用 Jotai

  • 状态相互依赖:A 依赖 B,B 依赖 C
  • 动态状态:运行时创建的 atom
  • 细粒度更新:需要精确控制哪个组件更新

八、选型决策树

你的状态需要跨组件共享吗?

├── 否 → useState / useReducer(组件内状态)

└── 是 → 状态是服务端数据吗?

    ├── 是 → TanStack Query / SWR(数据获取层)

    └── 否 → 更新频率高吗?

        ├── 否 → Context(简单共享)

        └── 是 → 状态结构复杂吗?

            ├── 否 → Zustand(store + selector)

            └── 是 → Jotai(atom + 自动依赖追踪)

九、实战:组合使用

实际项目中,不同类型的状态用不同的方案:

// 服务端数据 → TanStack Query
function UserProfile({ userId }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId)
  })
  return <div>{user.name}</div>
}

// 全局 UI 状态 → Zustand
const useUIStore = create((set) => ({
  sidebarOpen: true,
  theme: 'light',
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen }))
}))

function Layout() {
  const sidebarOpen = useUIStore((s) => s.sidebarOpen)
  return <div className={sidebarOpen ? 'sidebar-open' : ''}>...</div>
}

// 复杂派生状态 → Jotai
const filtersAtom = atom({ category: 'all', sort: 'newest' })
const filteredItemsAtom = atom((get) => {
  const items = get(itemsAtom)
  const filters = get(filtersAtom)
  return applyFilters(items, filters)
})

function ItemList() {
  const filtered = useAtom(filteredItemsAtom)[0]
  return <div>{filtered.map(...)}</div>
}

// 组件内局部状态 → useState
function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

十、与五个 Law 的关系

Law状态管理的角色
Law 1 (Environment)外部 store 在 Server 和 Client 都能用(useSyncExternalStore)
Law 2 (Description)状态变化触发 Description 重新计算
Law 3 (Ownership)谁拥有状态?组件内 vs 外部 store vs Context
Law 4 (Lane)状态更新的优先级由触发方式决定
Law 5 (Cost)状态更新的传播范围影响渲染成本

十一、总结

状态管理的本质问题是:

谁拥有状态?状态变化时,哪些组件需要知道?

方案状态归属更新粒度
useState组件内组件级
ContextProvider 级所有 consumer
Zustand外部 storeselector 级
Jotaiatom 级依赖自动追踪
TanStack Query服务端缓存query 级

没有最好的方案,只有最适合场景的方案。

关键是理解每种方案的边界,然后在正确的场景使用正确的工具。

这就是状态管理的 Ownership。