状态管理:在 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 中,首先要学会区分两种类型的状态:

  1. 服务器状态 (Server State)

    • 定义:存储在服务器端(如数据库、缓存、CMS)的数据。
    • 特点:非交互式,通常是异步获取的。
    • 管理:在 Server Components 中通过 async/await 直接获取。Next.js 的内置 fetch 缓存、路由段缓存以及按需重新验证 (revalidate) 机制是管理服务器状态的核心。像 React Query 或 SWR 这样的库,虽然传统上用于客户端,但它们的核心理念(缓存、重新验证)已经被 Next.js 的数据获取层所吸收。
  2. 客户端状态 (Client State)

    • 定义:只存在于客户端,并能响应用户交互而变化的数据。
    • 特点:同步的、交互式的。
    • 管理:这是本文的焦点。我们需要使用 useState, useReducer, Context 或第三方库来管理。

核心原则:尽可能将状态保留在服务器上。只有当状态必须由客户端交互直接控制时,才应将其视为客户端状态。

方案一:React Context - 官方推荐的共享状态方式

对于中小型应用或特定功能模块内的状态共享,React Context 是最直接、最原生的解决方案。

挑战Context Provider 本身需要使用 useStateuseReducer 来持有状态值,这意味着它必须是一个客户端组件。但我们的应用大部分是由 Server Components 构成的,如何在 Server Components 树中有效地使用一个客户端的 Provider 呢?

最佳实践:将 Provider 与其消费者解耦

  1. 创建 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;
    }
    
  2. 在根布局中包裹 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”

  3. 在客户端组件中消费 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 管理购物车状态

  1. 安装 Zustand

    npm install zustand
    
  2. 创建 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,
    }));
    
  3. 在客户端组件中使用 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、体积小 需要引入第三方依赖 购物车、复杂表单、需要高性能和精细化更新的全局或局部状态。

给开发者的建议

  1. 从 React Context 开始:对于大多数应用,先尝试使用 React 内置的 Context API。它可能已经足够满足你的需求。
  2. 按需引入 Zustand:当你发现 Context 带来了性能问题,或者你需要一个更结构化的、独立于组件树的状态容器时,再引入 Zustand。它的学习曲线平缓,可以与 Context 和平共存。
  3. 不要在客户端状态中存储服务器数据:避免将从数据库获取的数据(如文章列表)存入客户端 store。让 Next.js 的数据缓存和重新验证机制来管理这些服务器状态。

通过合理地运用这些工具,你可以在 RSC 和客户端组件和谐共存的世界里,构建出既响应迅速又易于维护的状态管理架构。

在下一篇文章中,我们将继续探讨构建复杂应用中的另一个常见环节:表单处理与校验,并学习如何使用像 React Hook Form 这样的库来简化这一过程。敬请期待!

更多推荐