長久以來,我出於習慣在每個專案中都使用 Redux,即使有些專案幾乎不需要客戶端狀態。Next.js 15 改變了這一點。過去大多數曾放在全域儲存中的資料,現在都應該放在伺服器上,而剩餘的客戶端狀態通常小到 Redux 顯得過於龐大。

以下是我現在實際決定一塊狀態應該放在哪裡的做法。


1. 大多數狀態已不再屬於客戶端

這是與舊版 React 應用最大的改變。從資料庫抓取的資料、使用者個人資訊、文章列表,當這些資料可以直接在 Server Component 中抓取時,就不需要再放在客戶端儲存中。

// 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 狀態、也不需要把資料放在全域儲存中讓少數元件讀取。這些資料在被使用的位置抓取,並以 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

主題、側邊欄收合狀態、開啟或關閉的 modal,這些都適合使用 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. 乾淨地結合伺服器狀態與客戶端狀態

一個常見的錯誤是把伺服器抓取的資料與僅限客戶端的狀態視為同一種東西。它們不是同一個東西,而混用它們會造成資料過時的 bug。

// 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

產品資料在每次請求時都從伺服器重新抓取。購物車本身(使用者實際加入的項目)是客戶端狀態,會在導航之間保留且不需要重新抓取。清楚區分這兩者可以避免客戶端狀態意外過時的 bug,因為它持有的是伺服器資料的副本而非僅參照它。


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,並在載入時重新還原,無需為每個欄位手動撰寫儲存與載入邏輯。


我實際的決策方式

這是從資料庫來的資料嗎? 在伺服器端抓取。不需要儲存。

少數相關元件共享,且更新不頻繁? 使用 Context。

更新頻繁,或從分散在應用中的元件讀取? 使用 Zustand。

需要跨頁面刷新保留? 使用帶 persist 中介軟體的 Zustand。

大多數專案在不同階段都會同時使用這三者:伺服器抓取處理大部分資料、Context 用於少數 UI 層級的切換、Zustand 則用於少數真正需要快速跨元件客戶端狀態的東西,例如購物車或多步驟表單。


摘要

工具 使用情境
Server Components 來自資料庫或 API 的資料,不需要客戶端儲存
React Context 少數相關元件共享,更新不頻繁
Zustand 更新頻繁,或狀態需跨無關元件讀取
Zustand + persist 需要跨頁面刷新保留的客戶端狀態

Next.js 15 真正的轉變不在於哪個狀態函式庫勝出,而是大多數狀態已不再需要客戶端函式庫。一旦伺服器抓取的資料被排除,剩下的狀態通常小到 Context 與 Zustand 的選擇取決於它的更新頻率。

我正是使用這種分割方式(伺服器抓取、Context 用於 UI 切換、Zustand 用於購物車類的狀態)來建置儀表板與模板。

實際程式碼範例:

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

你是否仍使用 Redux,還是 Zustand 也取代了它?歡迎在下方留言 👇


Anas,一位建置 SaaS 產品與高級模板的全端 Next.js 開發者。X: @ASheikh69751