很长一段时间里,我出于习惯在每个项目中都使用 Redux,即使有些项目几乎不需要客户端状态。Next.js 15 改变了这一点。过去大多数放在全局 store 中的内容,现在都应该放在服务端,而剩余的客户端状态通常很小,Redux 已经显得多余。

下面是我目前实际判断一段状态应该放在哪里的方法。


1. 大多数状态不再属于客户端

这是与旧版 React 应用最大的不同。从数据库获取的数据、用户资料信息、帖子列表等,当这些数据可以直接在服务端组件中获取时,就不再需要放在客户端 store 里。

// app/dashboard/page.tsx
import { getUser } from '@/lib/queries/users';
import { getPosts } from '@/lib/queries/posts';

export default async function DashboardPage() {
  const user = await getUser();
  const posts = await getPosts(user.id);

  return (
    <div>
      <h1>Welcome, {user.name}</h1>
      <PostList posts={posts} />
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

无需 useEffect、无需 loading 状态,也无需把这些数据放在全局 store 里让几个组件读取。它在被使用的地方获取,并作为 props 向下传递。这一步就移除了大量旧应用中需要 Redux 或 Context 的场景。


2. 使用 React Context 共享少量相关组件的状态

Context 仍然有它的用武之地,尤其是当一小部分相关组件需要共享状态,且该状态不会在每次按键时都变化时。

// context/ThemeContext.tsx
'use client';
import { createContext, useContext, useState } from 'react';

interface ThemeContextValue {
  theme: 'light' | 'dark';
  toggleTheme: () => void;
}

const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('dark');

  const toggleTheme = () => setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error('useTheme must be used inside ThemeProvider');
  return context;
}

Enter fullscreen mode Exit fullscreen mode

主题、侧边栏折叠状态、模态框的打开或关闭等,都很适合使用 Context,因为它们变化频率低,且只有少量组件关心它们。


3. Context 开始带来问题的地方

Context 在值变化时会重新渲染所有读取该值的组件,且没有内置的方式只订阅部分值。对于主题这类很少变化的状态来说,这没问题。但当状态频繁更新时,就会成为真正的问题。

// This causes every consumer to re-render on every keystroke
const [searchState, setSearchState] = useState({ query: '', filters: {}, results: [] });

Enter fullscreen mode Exit fullscreen mode

如果搜索输入框、筛选面板和结果列表都从同一个 Context 值中读取,那么每次输入一个字符都会导致这三个组件重新渲染,即使其中某些组件与这次按键无关。通常在这种情况下,我会切换到更适合频繁更新的方案。


4. 使用 Zustand 处理频繁更新或跨不相关组件的状态

当 Context 开始导致不必要的重新渲染,或者状态需要从自然上没有嵌套关系的组件中读取和更新时,我会选择 Zustand。

// store/useCartStore.ts
import { create } from 'zustand';

interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartStore {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  total: () => number;
}

export const useCartStore = create<CartStore>((set, get) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({
      items: [...state.items, item],
    })),
  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((item) => item.id !== id),
    })),
  total: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}));

Enter fullscreen mode Exit fullscreen mode

// components/CartButton.tsx
'use client';
import { useCartStore } from '@/store/useCartStore';

export function CartButton() {
  const items = useCartStore((state) => state.items);
  return <button>Cart ({items.length})</button>;
}

Enter fullscreen mode Exit fullscreen mode

与 Context 的关键区别在于最后一行。useCartStore((state) => state.items) 仅在 items 发生变化时才重新渲染该组件,而不是在 store 的每次更新时都重新渲染。其他地方读取 total() 的组件也不会因为完全不相关的购物车状态变化而重新渲染。


5. 干净地结合服务端状态与客户端状态

一个常见的错误是将服务端获取的数据和仅客户端状态视为同一种东西。它们不是,把它们混在一起会导致数据过时的问题。

// app/products/[id]/page.tsx
import { getProduct } from '@/lib/queries/products';
import { AddToCartButton } from '@/components/AddToCartButton';

export default async function ProductPage({ params }) {
  const { id } = await params;
  const product = await getProduct(id); // server state, fetched fresh

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <AddToCartButton product={product} /> {/* client state, cart store */}
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

产品数据在每次请求时都从服务端新鲜获取。而购物车本身(用户实际添加的内容)是客户端状态,它跨页面导航持久存在且不需要重新获取。明确区分这两者可以避免客户端状态意外过时的问题——因为它保存了服务端数据的副本而不是仅引用它。


6. 跨页面刷新持久化客户端状态

对于需要跨页面刷新保留的状态(如购物车或草稿表单数据),Zustand 内置了 persist 中间件:

// store/useCartStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],
      addItem: (item) => set((state) => ({ items: [...state.items, item] })),
      removeItem: (id) =>
        set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
      total: () => get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
    }),
    { name: 'cart-storage' }
  )
);

Enter fullscreen mode Exit fullscreen mode

这会自动将 store 保存到 localStorage,并在加载时重新注水,无需为每个字段手动编写保存和加载逻辑。


我实际的决策方式

这是来自数据库的数据吗? 在服务端获取。不需要 store。

一小部分相关组件共享它,且它很少变化吗? 使用 Context。

它经常更新,或被跨应用的不相关组件读取吗? 使用 Zustand。

它需要跨页面刷新保留吗? 使用带 persist 中间件的 Zustand。

大多数项目在不同场景下会同时使用这三种方案:服务端获取用于大部分数据,Context 用于少量 UI 层级的切换,Zustand 用于真正需要快速跨组件客户端状态的少量场景(如购物车或多步骤表单)。


总结

工具 适用场景
服务端组件 来自数据库或 API 的数据,无需客户端 store
React Context 少量相关组件共享,不频繁更新
Zustand 频繁更新,或跨不相关组件读取的状态
Zustand + persist 需要跨页面刷新保留的客户端状态

Next.js 15 中真正的转变不在于哪种状态库获胜,而在于大多数状态已经不再需要客户端库了。一旦服务端获取的数据被排除在外,剩下的状态通常很小,选择 Context 还是 Zustand 就取决于它变化的频率。

我在构建的仪表盘和模板中使用的正是这种划分:服务端获取、Context 用于 UI 切换、Zustand 用于任何购物车类的东西。

查看真实代码库:

获取模板: https://pixelanas.gumroad.com

你还在使用 Redux 吗,还是 Zustand 已经取代了它?欢迎在评论区留言 👇


Anas,全栈 Next.js 开发者,专注于构建 SaaS 产品和高级模板。X: @ASheikh69751