切换语言
切换主题

Next.js 状态管理选型指南:Zustand vs Jotai 实战对比

引言

周末凌晨,我盯着屏幕上的报错信息,第17次修改 Redux 的 action creator。项目就一个购物车功能,配置文件已经写了三个。

这时候,我突然意识到一个问题:为啥非得这么复杂?

说实话,很多人都遇到过这种困境。项目不大,用 Redux 感觉杀鸡用牛刀;改用 Context API,结果一个状态更新,半个页面都在重新渲染。性能面板上那一片红,看着就头疼。

这就是为什么这两年,Zustand 和 Jotai 突然火了起来——它们承诺的是”轻量级”和”高性能”。但问题又来了:这俩到底选哪个?

老实讲,一开始我也搞不清楚。Zustand 说自己简单,Jotai 说自己性能好,听起来都挺有道理。直到我在实际项目里都用过之后,才算明白它们的区别到底在哪儿。

这篇文章,我会跟你聊聊:

  • Redux 和 Context 为啥不好用(真实的坑)
  • Zustand 和 Jotai 本质上有什么不同
  • 什么场景该选哪个(决策树)
  • Next.js App Router 里怎么用(踩坑经验)

咱们直接开始。

为什么不用 Redux 和 Context?

Redux 的”重”,到底重在哪儿?

先说 Redux。它不是不好用,只是对很多项目来说,太过了。

你要写 action types、action creators、reducers,还得配置 store。一个简单的”添加到购物车”功能,可能要碰三四个文件。这些样板代码,写多了真的烦。

更关键的是,如果团队里有新人,Redux 的学习曲线挺陡的。什么是 dispatch?为什么要用 pure function?middleware 又是干嘛的?这些概念,得花时间理解。

对于一个待办清单或者个人博客这种小项目,Redux 就像开坦克去买菜——能到,但没必要。

Context API 的性能陷阱

那 Context 呢?确实简单,官方自带,不用装任何库。

但有个大问题:性能。

Context 的工作原理是,只要 Provider 的 value 变了,所有消费这个 Context 的组件都会重新渲染。哪怕你只用了其中一个字段,整个组件照样要重新走一遍渲染流程。

我之前做过一个表单,用 Context 管理字段状态。结果一个输入框的 onChange,导致页面上20个组件全部重渲染。Chrome DevTools 的火焰图,看着就难受。

你可以用 memo、useMemo、拆分 Context 这些技巧来优化,但说实话,优化下来的代码,已经不比 Redux 简单多少了。

轻量级方案的吸引力

这就是为什么 Zustand 和 Jotai 这么受欢迎。

它们给出的承诺很明确:

  • API 简单,上手快(Zustand 10分钟就能学会)
  • 性能优化是内置的,不需要你手动搞
  • 包体积小(Zustand 就 1KB,gzip 后更小)

数据也说明了这点。根据 2025 年的统计,Zustand 的使用量过去一年增长了 150%。越来越多的开发者,开始放弃 Redux,转向这些轻量级方案。

但这就引出了新问题:Zustand 和 Jotai,选哪个?

Zustand vs Jotai 核心差异

这俩库,表面上看都是”轻量级状态管理”,但底层设计思路完全不一样。

状态模型:单一商店 vs 原子小摊

官方文档里有句话说得特别清楚:“Zustand is like Redux. Jotai is like Recoil.”

Zustand 本质上是个简化版的 Redux。你有一个 store,所有状态都在里面。就像一个大商场,所有商品都集中管理。

// Zustand:一个大 store
const useStore = create((set) => ({
  user: null,
  cart: [],
  theme: 'light',
  // 所有状态都在这里
}))

Jotai 则是原子化的。每个状态都是独立的 atom,就像一个个小摊位,各自为政。

// Jotai:独立的 atoms
const userAtom = atom(null)
const cartAtom = atom([])
const themeAtom = atom('light')

这个设计差异,直接决定了它们适合的场景。

存储位置:模块外 vs 组件树内

Zustand 的 store 是模块级的,存在 React 之外。你可以在任何地方导入它、更新它,不需要 Provider。

Jotai 的 atoms 存在组件树里,依赖 Context。你必须在根组件包一层 Provider,atoms 的状态才能在组件间共享。

这意味着什么?

如果你需要在 React 组件外更新状态(比如在某个工具函数里、WebSocket 回调里),Zustand 会方便很多。Jotai 虽然也能做到,但得绕一些弯。

性能特性:手动优化 vs 自动最优

性能上,两者策略不同。

Jotai 的原子化订阅,默认就是最优的。组件只订阅它用到的 atoms,其他 atoms 更新不会触发重渲染。

Zustand 需要你用 selector 来优化:

// 不推荐:订阅整个 store
const store = useStore()

// 推荐:用 selector 只订阅需要的部分
const user = useStore(state => state.user)

不过话说回来,Zustand 的 selector 写起来也不麻烦,而且很直观。只是你得记得用,不然可能踩坑。

一句话总结

  • Zustand:单一 store,在 React 外,需要手动 selector
  • Jotai:原子化 atoms,在 React 内,性能自动优化

选哪个,得看你的项目特点。

Zustand: 1KB
包体积
gzip后大小,Zustand更轻量
Zustand: 10分钟
学习成本
上手时间,Zustand更简单
Zustand: 手动
性能优化
需要手动selector vs 自动细粒度更新
Zustand: 80%
适用场景
大多数项目选Zustand,复杂场景选Jotai

什么场景选 Zustand?

先说 Zustand。如果你的项目符合以下特点,Zustand 基本上是最佳选择。

中小型应用,不想搞太复杂

说实话,大部分项目都不需要复杂的状态管理。

一个电商网站,需要管理的全局状态无非是:用户信息、购物车、主题配置。这种场景,Zustand 再合适不过。

它的 API 简单到几乎不需要学习。看一个完整示例:

// store.js
import create from 'zustand'

const useStore = create((set) => ({
  cart: [],
  addToCart: (item) => set((state) => ({
    cart: [...state.cart, item]
  })),
  removeFromCart: (id) => set((state) => ({
    cart: state.cart.filter(item => item.id !== id)
  })),
}))

// CartButton.jsx
function CartButton() {
  const addToCart = useStore(state => state.addToCart)
  return <button onClick={() => addToCart(item)}>加入购物车</button>
}

// CartCount.jsx
function CartCount() {
  const count = useStore(state => state.cart.length)
  return <span>{count}</span>
}

看到没?没有 Provider,没有 action types,没有 reducer。直接定义状态和方法,直接用。

团队新人看到这代码,10分钟就能上手。

需要在 React 外更新状态

这是 Zustand 的一个独特优势。

比如你有个 WebSocket 连接,收到消息需要更新状态:

// websocket.js
import { useStore } from './store'

socket.on('message', (data) => {
  // 直接调用 store 方法,不需要在组件里
  useStore.getState().updateMessages(data)
})

或者你在一个工具函数里,需要根据当前状态做判断:

// utils.js
import { useStore } from './store'

export function checkPermission() {
  const user = useStore.getState().user
  return user?.role === 'admin'
}

这种场景,Jotai 就不太方便了。它的 atoms 绑定在组件树里,组件外访问得绕弯。

Next.js SSR 友好

Zustand 在 Next.js 里的支持特别完善。

官方文档专门有一章讲 Next.js 集成,提供了 App Router 的最佳实践。而且社区里的踩坑经验也多,遇到问题基本都能找到解决方案。

如果你在用 Next.js 13+ 的 App Router,Zustand 是目前最稳的选择之一。后面我会详细讲配置方法。

什么时候不适合用 Zustand?

但有一种场景,Zustand 可能不是最优解:状态之间有复杂的派生关系。

比如你有一个筛选器,有10个筛选条件,每个条件的可选项还依赖其他条件的当前值。这种场景下,Zustand 的代码会变得比较绕。

这时候,Jotai 的原子化设计就派上用场了。

什么场景选 Jotai?

Jotai 的原子化设计,在某些场景下真的是神器。

复杂的状态依赖关系

这是 Jotai 最擅长的领域。

假设你在做一个商品筛选器:

  • 有”品牌”、“价格区间”、“评分”等筛选条件
  • 可选的品牌列表,取决于当前的价格区间
  • 最终的商品列表,由所有筛选条件共同决定

用 Zustand 写,你得手动管理这些依赖,代码容易乱。

Jotai 的写法就很清晰:

// 基础 atoms
const brandAtom = atom([])
const priceRangeAtom = atom([0, 1000])
const ratingAtom = atom(0)

// 派生 atom:可选品牌(依赖价格区间)
const availableBrandsAtom = atom((get) => {
  const priceRange = get(priceRangeAtom)
  return fetchBrands(priceRange) // 自动响应 priceRange 变化
})

// 派生 atom:筛选后的商品(依赖所有筛选条件)
const filteredProductsAtom = atom((get) => {
  const brands = get(brandAtom)
  const priceRange = get(priceRangeAtom)
  const rating = get(ratingAtom)
  return products.filter(/* 筛选逻辑 */)
})

看到没?每个 atom 只关心它依赖的其他 atoms,Jotai 会自动追踪依赖关系。哪个变了,相关的 atoms 自动更新。

组件里用起来也简单:

function FilterPanel() {
  const [brands, setBrands] = useAtom(brandAtom)
  const availableBrands = useAtomValue(availableBrandsAtom)
  // brands 变化,availableBrands 自动重新计算
}

这种场景下,Jotai 的代码比 Zustand 清晰太多。

对性能要求极致的场景

Jotai 的原子化订阅,性能是真的好。

假设你有一个实时数据看板,页面上有50个组件,每个组件显示不同的数据指标。用 Context 或者不优化的 Zustand,一个数据更新可能导致一堆组件重渲染。

Jotai 不会。每个组件只订阅自己的 atom,其他 atoms 更新完全不影响它。

官方文档里说得很明确:“This is the most performant by default.” 组件只订阅特定的 atoms,那个 atom 变了,才重渲染。

需要代码分割的大型应用

Jotai 的 atoms 可以按需加载。

你可以把 atoms 分散在不同的文件里,只在用到的时候导入。这对大型应用的首屏加载很有帮助。

Zustand 的 store 通常是一整块,虽然也能分割,但没有 Jotai 那么自然。

深度使用 Suspense 的项目

如果你的项目大量使用 React Suspense(比如做异步数据加载),Jotai 有原生支持。

异步 atom 写起来特别直观:

const userAtom = atom(async () => {
  const res = await fetch('/api/user')
  return res.json()
})

function UserProfile() {
  const user = useAtomValue(userAtom)
  // 自动 Suspense,等数据加载完才渲染
  return <div>{user.name}</div>
}

Zustand 虽然也能配合 Suspense,但需要额外的包装,没有 Jotai 这么丝滑。

Jotai 的学习成本

不过话说回来,Jotai 的概念比 Zustand 稍微绕一点。

atom 的读写、派生 atom、异步 atom,这些概念需要一点时间理解。如果团队里有新手,上手速度会比 Zustand 慢一些。

而且 Jotai 的文档,坦白说,没有 Zustand 那么友好。你得多看几遍才能搞清楚各种 API 的用法。

Next.js App Router 最佳实践

理论说完了,咱们来点实战的。在 Next.js 13+ 的 App Router 里用这两个库,有些坑得提前知道。

Zustand 在 Next.js 里的正确姿势

大坑:不要用全局 store

很多人(包括我一开始)会直接这么写:

// ❌ 错误:全局 store
import create from 'zustand'

const useStore = create((set) => ({
  user: null,
  setUser: (user) => set({ user })
}))

这在客户端渲染没问题,但在 Next.js 的 SSR 环境里,这个 store 会在多个请求之间共享。用户 A 的数据可能被用户 B 看到,安全隐患。

官方推荐的做法是 Store Factory 模式:

// lib/store.js
import { createStore } from 'zustand/vanilla'

export function createUserStore(initialState) {
  return createStore((set) => ({
    user: initialState?.user || null,
    setUser: (user) => set({ user })
  }))
}

然后在客户端组件里创建 Provider:

// components/StoreProvider.jsx
'use client'

import { createContext, useContext, useRef } from 'react'
import { useStore } from 'zustand'
import { createUserStore } from '@/lib/store'

const StoreContext = createContext(null)

export function StoreProvider({ children, initialState }) {
  const storeRef = useRef()
  if (!storeRef.current) {
    storeRef.current = createUserStore(initialState)
  }

  return (
    <StoreContext.Provider value={storeRef.current}>
      {children}
    </StoreContext.Provider>
  )
}

export function useUserStore(selector) {
  const store = useContext(StoreContext)
  return useStore(store, selector)
}

在根布局里使用:

// app/layout.jsx
import { StoreProvider } from '@/components/StoreProvider'

export default function RootLayout({ children }) {
  // 可以在这里预取数据
  const initialState = { user: null }

  return (
    <html>
      <body>
        <StoreProvider initialState={initialState}>
          {children}
        </StoreProvider>
      </body>
    </html>
  )
}

这样每个请求都有独立的 store,不会串数据。

注意事项:

  • Server Components 不能直接读写 store
  • 数据预取在服务端做,通过 initialState 传给客户端
  • 不要在根布局里阻塞式地获取数据,会影响性能

Jotai 的 SSR Hydration 问题

Jotai 在 Next.js 里最大的坑是 hydration 错误

必须为每个请求创建独立 Provider:

// app/providers.jsx
'use client'

import { Provider } from 'jotai'

export function Providers({ children }) {
  return <Provider>{children}</Provider>
}
// app/layout.jsx
import { Providers } from './providers'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

处理服务端数据:

如果你需要把服务端数据注入到 atoms,用 useHydrateAtoms:

'use client'

import { useHydrateAtoms } from 'jotai/utils'
import { userAtom } from '@/atoms'

export function HydrateAtoms({ initialUser, children }) {
  useHydrateAtoms([[userAtom, initialUser]])
  return children
}

但这有个坑:useHydrateAtoms 只在首次渲染生效。如果你在 App Router 里用 router.push 跳转,第二次访问同一个 atom 不会重新 hydrate。

解决方案:

  1. 把 Provider 放在 template.tsx 而不是 layout.tsx(每次路由都重新创建)
  2. 或者用页面级别的 Provider,而不是全局 Provider

atomWithStorage 的 hydration 错误:

如果你用 atomWithStorage 存储表单数据,可能遇到服务端渲染和客户端 hydration 不一致的问题:

  • 服务端:表单是空的
  • 客户端:从 localStorage 读取,表单有值
  • 结果:React hydration mismatch 错误

解决方案:在 useEffect 里再填充表单,或者用 useHydrateAtoms + useSyncExternalStore

通用原则(两个库都适用)

  1. Server Components 不能用状态管理

    • Server Components 没有 hooks,不能用 useStoreuseAtom
    • 需要状态的组件,标记为 'use client'
  2. Provider 位置很重要

    • 放得越深越好,有助于 Next.js 优化静态部分
    • 但也要方便所有需要状态的组件访问
  3. 避免阻塞根布局

    • 不要在 root layout 里用 await fetch() 获取用户数据
    • 这会抵消 streaming 和 Server Components 的性能优势
    • 用专门的客户端组件处理数据获取和初始化

这些坑,我都踩过。记住这些原则,能省很多调试时间。

我的选型建议

聊了这么多,你可能还是会问:到底选哪个?

老实讲,没有标准答案。但我可以给你一个决策树。

快速决策树

如果你的项目是…

  1. 简单的个人项目/小型应用
    → 先用 Context API,够用就不换
    → 如果遇到性能问题,上 Zustand

  2. 中型 SaaS 应用/电商网站
    → 直接上 Zustand
    → 简单、稳定、团队容易上手

  3. 复杂的数据看板/实时应用
    → 考虑 Jotai
    → 状态依赖关系复杂,Jotai 更清晰

  4. 大团队/严格规范要求
    → Redux Toolkit 可能更合适
    → 有更多的约束和最佳实践

  5. 需要在 React 外更新状态
    → Zustand 一择
    → Jotai 虽然也能做,但不自然

  6. 深度使用 Suspense
    → Jotai 更丝滑
    → 异步 atoms 原生支持

渐进式策略

我个人推荐渐进式选型:

  1. 第一阶段:Context API

    • 项目刚起步,状态不多,先用 Context
    • 够用就不要换,不要过早优化
  2. 第二阶段:Zustand

    • Context 性能开始有问题
    • 或者全局状态变复杂了
    • Zustand 能解决80%的场景
  3. 第三阶段:Jotai 或保持 Zustand

    • 状态依赖关系特别复杂 → Jotai
    • 否则保持 Zustand 就好
    • 不要为了技术而技术

可以混用吗?

可以!

Zustand 和 Jotai 不冲突。我见过有项目:

  • 用 Zustand 管理全局配置(用户、主题)
  • 用 Jotai 管理复杂表单状态

这完全没问题。选最合适的工具,解决具体的问题。

我的实践经验

分享一下我自己的选择:

  • 个人博客:不用状态管理,Server Components + URL state 够了
  • 中后台项目:Zustand,配合 React Query 处理服务端状态
  • 实时数据看板:Jotai,状态依赖关系太复杂,Zustand 写起来很绕

关键是,不要一开始就纠结技术选型。先用最简单的方案,遇到问题再升级。

很多项目,Context API 就够用了。不要低估它。

结论

说回开头的问题。

Redux 太重?确实,对很多项目来说,它是杀鸡用牛刀。

Context 性能差?是的,不优化的话,重渲染确实会成为瓶颈。

Zustand 还是 Jotai?看场景:

  • 大部分情况,Zustand 够用,简单稳定
  • 复杂状态依赖,Jotai 更优雅
  • 不确定?先用 Zustand,真不行再换

Next.js App Router 怎么用?记住三点:

  • 别用全局 store
  • 每个请求独立的 Provider
  • Server Components 不要碰状态

最后说一句。

技术选型没有标准答案。别被网上的对比文章牵着走,包括这篇。最重要的是,选一个你和团队都舒服的方案,能解决实际问题就行。

如果你现在正在犹豫,我的建议是:挑一个,写个 demo,试试就知道了。10分钟的实践,胜过10篇文章。

祝你选到合适的工具。

常见问题

Redux、Context、Zustand、Jotai有什么区别?
Redux:
• 优点:功能强大,生态丰富,适合超大型项目
• 缺点:太重,需要写很多样板代码,学习成本高
• 适用:超大型项目,需要复杂状态管理

Context API:
• 优点:React内置,不需要额外库
• 缺点:性能差,一个状态更新可能导致整个子树重新渲染
• 适用:简单的全局状态,不频繁更新

Zustand:
• 优点:简单直接,代码量少,学习成本低
• 缺点:不适合超大型项目
• 适用:大多数项目,中小型项目

Jotai:
• 优点:原子化状态,细粒度更新,性能好
• 缺点:学习成本高,代码更复杂
• 适用:大型项目,需要极致性能

选择建议:大多数项目选Zustand,大型项目或需要极致性能选Jotai。
什么时候该用Zustand,什么时候该用Jotai?
选择Zustand的场景:
• 大多数项目
• 中小型项目
• 需要简单直接的状态管理
• 团队学习成本低
• 代码量少

选择Jotai的场景:
• 大型项目
• 需要极致性能
• 状态更新频繁
• 需要细粒度更新
• 团队有经验

决策树:
• 项目规模小 → Zustand
• 项目规模大 → Jotai
• 性能要求高 → Jotai
• 简单易用 → Zustand

建议:大多数项目选Zustand,只有大型项目或需要极致性能时才选Jotai。
Context API为什么性能差?
问题:一个状态更新,整个Context的消费者都会重新渲染。

原因:
• Context API的设计导致所有消费者都会收到更新
• 即使只更新了一个字段,所有消费者都会重新渲染
• 没有细粒度更新机制

示例:
```tsx
// 更新user.name,但所有使用user的组件都会重新渲染
const [user, setUser] = useState({ name: 'John', email: 'john@example.com' })
```

解决方案:
• 拆分Context(按功能拆分)
• 使用useMemo优化
• 使用Zustand或Jotai替代

建议:如果状态更新频繁,不要用Context API,改用Zustand或Jotai。
Next.js App Router中怎么使用Zustand?
安装:
```bash
npm install zustand
```

创建store:
```tsx
import { create } from 'zustand'

interface Store {
count: number
increment: () => void
}

export const useStore = create<Store>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}))
```

在组件中使用:
```tsx
'use client'
import { useStore } from '@/store'

export function Counter() {
const { count, increment } = useStore()
return <button onClick={increment}>{count}</button>
}
```

关键点:
• 使用store的组件必须是Client Component('use client')
• Server Components不能使用状态管理
• 可以在多个Client Component之间共享状态

注意:Zustand store是全局的,可以在任何Client Component中使用。
Next.js App Router中怎么使用Jotai?
安装:
```bash
npm install jotai
```

创建atom:
```tsx
import { atom } from 'jotai'

export const countAtom = atom(0)
export const incrementAtom = atom(null, (get, set) => {
set(countAtom, get(countAtom) + 1)
})
```

在组件中使用:
```tsx
'use client'
import { useAtom } from 'jotai'
import { countAtom, incrementAtom } from '@/atoms'

export function Counter() {
const [count, increment] = useAtom(countAtom)
return <button onClick={increment}>{count}</button>
}
```

关键点:
• 使用atom的组件必须是Client Component
• Server Components不能使用状态管理
• 原子化设计,细粒度更新

优势:
• 只有使用特定atom的组件才会更新
• 性能更好
• 适合大型项目

注意:Jotai的学习成本比Zustand高,但性能更好。
Server Components可以使用状态管理吗?
不可以。Server Components不能使用任何状态管理库。

原因:
• Server Components在服务端渲染
• 没有客户端状态
• 不能使用hooks(useState、useEffect等)

解决方案:
• 在Client Component中使用状态管理
• Server Components通过props传递数据
• 使用Server Components获取数据,Client Components管理状态

示例:
```tsx
// Server Component(获取数据)
export default async function Page() {
const data = await fetchData()
return <ClientComponent initialData={data} />
}

// Client Component(管理状态)
'use client'
export function ClientComponent({ initialData }) {
const [data, setData] = useState(initialData)
// 使用Zustand或Jotai管理状态
}
```

关键点:
• Server Components负责数据获取
• Client Components负责状态管理和交互
• 通过props传递数据

建议:合理划分Server Components和Client Components的职责。

15 分钟阅读 · 发布于: 2025年12月19日 · 修改于: 2026年1月22日

评论

使用 GitHub 账号登录后即可评论

相关文章