状态管理:在 Next.js 中使用 React Context 或 Zustand
状态管理:在 Next.js 中使用 React Context 或 Zustand
作者:码力无边
随着应用功能的日益丰富,一个不可避免的问题摆在了我们面前:如何有效地管理和共享状态? 这里的“状态”可以是指用户的登录信息、购物车的内容、UI 的主题(暗黑/明亮模式)、表单的输入数据等等。
在传统的 React SPA 中,状态管理方案已经非常成熟,从简单的 useState
+ props drilling,到 React Context
,再到强大的第三方库如 Redux, MobX, Zustand。
然而,Next.js App Router 和 React Server Components (RSC) 的引入,为这个经典问题带来了新的维度和挑战。Server Components 本身是无状态的,它们在服务器上渲染一次就结束了生命周期。这意味着,任何需要跨组件共享、并能响应用户交互而变化的状态,都必须存在于客户端。
这就引出了一系列新问题:
- 如何在 Server Components 和 Client Components 之间传递初始状态?
- 传统的
Context
在 RSC 环境下如何工作? - 像 Redux, Zustand 这样的库,在新范式下的最佳实践是什么?
本文将为你厘清这些问题,并提供在现代 Next.js 应用中进行状态管理的实用策略和方案。
状态的分类:服务器状态 vs. 客户端状态
在 App Router 中,首先要学会区分两种类型的状态:
-
服务器状态 (Server State):
- 定义:存储在服务器端(如数据库、缓存、CMS)的数据。
- 特点:非交互式,通常是异步获取的。
- 管理:在 Server Components 中通过
async/await
直接获取。Next.js 的内置fetch
缓存、路由段缓存以及按需重新验证 (revalidate
) 机制是管理服务器状态的核心。像 React Query 或 SWR 这样的库,虽然传统上用于客户端,但它们的核心理念(缓存、重新验证)已经被 Next.js 的数据获取层所吸收。
-
客户端状态 (Client State):
- 定义:只存在于客户端,并能响应用户交互而变化的数据。
- 特点:同步的、交互式的。
- 管理:这是本文的焦点。我们需要使用
useState
,useReducer
,Context
或第三方库来管理。
核心原则:尽可能将状态保留在服务器上。只有当状态必须由客户端交互直接控制时,才应将其视为客户端状态。
方案一:React Context - 官方推荐的共享状态方式
对于中小型应用或特定功能模块内的状态共享,React Context 是最直接、最原生的解决方案。
挑战:Context Provider
本身需要使用 useState
或 useReducer
来持有状态值,这意味着它必须是一个客户端组件。但我们的应用大部分是由 Server Components 构成的,如何在 Server Components 树中有效地使用一个客户端的 Provider 呢?
最佳实践:将 Provider 与其消费者解耦
-
创建 Provider 组件:创建一个专门的、标记为
"use client"
的组件来作为你的 Context Provider。components/theme-provider.tsx
"use client";import { createContext, useContext, useState, ReactNode } from 'react';type Theme = 'light' | 'dark'; type ThemeContextType = {theme: Theme;toggleTheme: () => void; };// 1. 创建 Context,可以给定一个默认值 const ThemeContext = createContext<ThemeContextType | undefined>(undefined);// 2. 创建 Provider 组件 export function ThemeProvider({ children }: { children: ReactNode }) {const [theme, setTheme] = useState<Theme>('light');const toggleTheme = () => {setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));};return (<ThemeContext.Provider value={{ theme, toggleTheme }}>{children}</ThemeContext.Provider>); }// 3. 创建一个自定义 Hook,方便消费 Context export function useTheme() {const context = useContext(ThemeContext);if (context === undefined) {throw new Error('useTheme must be used within a ThemeProvider');}return context; }
-
在根布局中包裹 Provider:将这个客户端的 Provider 组件尽可能地“推高”到组件树的顶层,通常是在根
layout.tsx
中。app/layout.tsx
import { ThemeProvider } from '@/components/theme-provider';export default function RootLayout({ children }: { children: React.Node }) {return (<html><body><ThemeProvider>{/* ThemeProvider 是客户端组件,但它的 children 仍然可以是服务端组件! */}<Header /> {/* Server Component */}<main>{children}</main> {/* Server or Client Component */}</ThemeProvider></body></html>); }
关键点:虽然
ThemeProvider
是一个客户端组件,但这并不意味着它的所有子组件都变成了客户端组件。React 非常智能,它允许你在一个客户端组件的“插槽”(children
)中渲染服务端组件。这被称为 “Server/Client Component Interleaving”。 -
在客户端组件中消费 Context:现在,你可以在任何需要主题状态的客户端组件中使用
useTheme
Hook。components/theme-toggle-button.tsx
"use client";import { useTheme } from '@/components/theme-provider';export default function ThemeToggleButton() {const { theme, toggleTheme } = useTheme();return (<button onClick={toggleTheme}>切换到 {theme === 'light' ? '暗黑' : '明亮'} 模式</button>); }
Context 的优缺点:
- 优点:React 内置,无需额外依赖;类型安全;对于中低复杂度的状态共享非常直观。
- 缺点:当状态更新频繁或状态对象庞大时,可能会导致所有消费者组件不必要的重渲染,存在性能问题。
方案二:Zustand - 简约而强大的第三方库
当应用规模变大,或者你需要更精细的性能控制时,就需要引入专门的状态管理库了。在 RSC 时代,像 Zustand 这样轻量、简约、不依赖 Context
的库变得尤为受欢迎。
Zustand 的核心理念是使用 Hooks 来订阅状态变化的“切片 (slices)”,组件只会因为它所订阅的那部分状态变化而重渲染。
为什么 Zustand 特别适合 App Router?
- 不依赖 Provider:Zustand 的 store 是在 React 组件树之外创建的。你不需要像 Context 那样在顶部包裹一个 Provider,从而避免了“整个子树都是客户端”的担忧。
- 可在任何地方访问:你可以在任何组件(只要是客户端组件)中通过 Hook 访问 store,无需担心层级问题。
实战:使用 Zustand 管理购物车状态
-
安装 Zustand
npm install zustand
-
创建 Store
store/cart-store.ts
import { create } from 'zustand';type Product = {id: number;name: string;price: number; };type CartState = {items: Product[];addItem: (item: Product) => void;removeItem: (itemId: number) => void;totalItems: () => number; };export const useCartStore = create<CartState>((set, get) => ({items: [],addItem: (item) => set((state) => ({ items: [...state.items, item] })),removeItem: (itemId) => set((state) => ({ items: state.items.filter((item) => item.id !== itemId) })),totalItems: () => get().items.length, }));
-
在客户端组件中使用 Store
components/AddToCartButton.tsx
"use client";import { useCartStore } from '@/store/cart-store';export default function AddToCartButton({ product }) {const addItem = useCartStore((state) => state.addItem);return (<button onClick={() => addItem(product)}>添加到购物车</button>); }
components/CartDisplay.tsx
"use client";import { useCartStore } from '@/store/cart-store';export default function CartDisplay() {// 我们可以选择性地订阅状态const totalItems = useCartStore((state) => state.totalItems());const items = useCartStore((state) => state.items);return (<div><p>购物车中的商品数量: {totalItems}</p><ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul></div>); }
注意:即使
items
数组变化导致CartDisplay
重新渲染,AddToCartButton
也不会,因为它只订阅了addItem
这个不会变化的函数。这就是 Zustand 的性能优势所在。
总结与选择建议
在 Next.js App Router 中,状态管理需要一种新的思维方式:明确区分服务器状态和客户端状态,并将客户端状态的管理限制在必要的范围内。
方案 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
React Context | 原生、简单、类型安全 | 存在性能隐患,Provider 必须是客户端组件 | 主题切换、用户认证信息等更新不频繁、作用域明确的全局状态。 |
Zustand | 性能好、API 简洁、无需 Provider、体积小 | 需要引入第三方依赖 | 购物车、复杂表单、需要高性能和精细化更新的全局或局部状态。 |
给开发者的建议:
- 从 React Context 开始:对于大多数应用,先尝试使用 React 内置的 Context API。它可能已经足够满足你的需求。
- 按需引入 Zustand:当你发现 Context 带来了性能问题,或者你需要一个更结构化的、独立于组件树的状态容器时,再引入 Zustand。它的学习曲线平缓,可以与 Context 和平共存。
- 不要在客户端状态中存储服务器数据:避免将从数据库获取的数据(如文章列表)存入客户端 store。让 Next.js 的数据缓存和重新验证机制来管理这些服务器状态。
通过合理地运用这些工具,你可以在 RSC 和客户端组件和谐共存的世界里,构建出既响应迅速又易于维护的状态管理架构。
在下一篇文章中,我们将继续探讨构建复杂应用中的另一个常见环节:表单处理与校验,并学习如何使用像 React Hook Form 这样的库来简化这一过程。敬请期待!