Next.js 狀態管理 (State Management)

在進入 App Router 世界後,前端開發者面臨最大的思維轉換之一就是「狀態放在哪裡?」。以前在 SPA (Single Page Application) 中,我們習慣把所有東西都塞進 Redux 或 Context,但在 Next.js 15+ 中,這不再是最佳解。

理解不同類型的狀態及其歸宿,是寫出高效能 Next.js 應用的關鍵。

狀態光譜:該把狀態放哪裡?

在決定引入任何狀態管理庫之前,請先依照以下優先順序思考:

URL State (網址狀態) - 最推薦

這是最容易被忽視,但最強大的狀態儲存位置。 舉凡:搜尋關鍵字、分頁頁碼、篩選條件、Modal 開關,都應該優先放在 URL Search Params (Query String) 中。

  • 優點:可分享 (Shareable)、可書籤 (Bookmarkable)、重新整理不丟失、瀏覽器上一頁/下一頁原生支援。
  • 實作:使用 useSearchParamsrouter.push / Link

Server State (伺服器狀態)

指的就是你的後端資料 (Database data)。 在 Next.js 中,透過 Server Components 直接 fetch 資料,並透過 props 傳給 Client Components,是最高效的做法。不要再用 useEffect 在前端 fetch 資料然後存進 Redux 了。

Local State (元件狀態)

僅在單一元件或極少數子元件中使用的狀態。

  • 工具useState, useReducer, useRef

Global Client State (全域用戶端狀態)

只有當上述三者都無法滿足需求時,才考慮全域狀態。 例如:購物車內容、音樂播放器的播放清單、全域 Toast 通知、使用者偏好設定 (Dark Mode)

  • 工具:Zustand (推薦), Jotai, Recoil, React Context (適合簡單場景)。

為什麼選擇 Zustand?

在眾多 React 狀態管理庫中,Zustand 脫穎而出成為 Next.js 社群的首選,原因如下:

  1. 極簡 (Minimalist):API 非常簡單,沒有 Redux 的 boilerplate (樣板程式碼)。
  2. Hook-based:完全符合 React Hooks 的使用習慣。
  3. 效能優化:內建 Selectors,只有當你訂閱的特定狀態改變時,元件才會重新渲染 (Re-render)。
  4. 彈性:可以在 React 元件之外 (如純 JS 函式中) 讀寫狀態。

Zustand 核心概念與語法

Zustand 的設計靈感來自於 Flux 架構,但將其簡化到了極致。

核心組成

  • Store (倉庫):存放狀態 (State)更新狀態的方法 (Actions) 的地方。
  • Hook:你的 store 本質上就是一個自訂 Hook。
  • Immutable Updates:雖然 Zustand 讓你寫起來像 mutable (直接修改),但背後它鼓勵你遵循 immutable 原則(類似 React 的 setState)。

基本語法範例

在進入複雜的購物車範例前,我們先看一個最簡單的「計數器」:

import { create } from 'zustand'

// 1. 定義 Store 的型別 (State + Actions)
type Store = {
  count: number
  inc: () => void
}

// 2. 建立 Store
const useStore = create<Store>((set) => ({
  count: 1,
  // set 用來更新狀態。它接受一個函數,該函數接收當前的 state
  inc: () => set((state) => ({ count: state.count + 1 })),
}))

// 3. 在元件中使用
function Counter() {
  const { count, inc } = useStore() // 就像用 useState 一樣簡單
  return (
    <div>
      <span>{count}</span>
      <button onClick={inc}>one up</button>
    </div>
  )
}

set 函式的奧義

create 裡面,你會拿到一個 set 函式。它是更新狀態的唯一鑰匙。

  • 合併更新 (Merging):Zustand 預設會進行淺層合併 (shallow merge)。你只需要回傳「你想改變的那個屬性」,其他屬性會自動保留。
    • set({ count: 2 }) -> 只改 count,其他不動。

了解基礎後,我們來看一個完整的 Next.js 購物車實作。

Zustand 實戰範例

安裝

npm install zustand

建立 Store

我們以「購物車」為例。Zustand 的 store 是一個 Hook,建議命名以 use 開頭。

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

// 定義 State 與 Action 的型別
interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
}

interface CartStore {
  items: CartItem[];
  isOpen: boolean;
  // Actions
  toggleCart: () => void;
  addItem: (item: Omit<CartItem, 'quantity'>) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
  // Computed (雖然可以寫成 function,但也可以透過 getter 概念實作)
  totalPrice: () => number;
}

export const useCartStore = create<CartStore>((set, get) => ({
  items: [],
  isOpen: false,

  toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),

  addItem: (newItem) => {
    set((state) => {
      const existingItem = state.items.find((item) => item.id === newItem.id);
      if (existingItem) {
        // 如果商品已存在,數量 +1 (Immutable 更新)
        return {
          items: state.items.map((item) =>
            item.id === newItem.id ? { ...item, quantity: item.quantity + 1 } : item
          ),
        };
      }
      // 新增商品
      return { items: [...state.items, { ...newItem, quantity: 1 }] };
    });
  },

  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((item) => item.id !== id),
    })),

  clearCart: () => set({ items: [] }),

  // 計算總金額 (也可以在 Component 中透過 selector 計算)
  totalPrice: () => {
    return get().items.reduce((total, item) => total + item.price * item.quantity, 0);
  },
}));

在元件中使用與 Selectors (選擇器)

在 Zustand 中,Selector 是一個函數,用來從 Store 中「選取」你需要的資料片段。這是 Zustand 效能優化的核心。

為什麼需要 Selectors?

當你直接呼叫 useCartStore() 時,你會取得整個 Store 物件。這意味著 Store 中任何一個屬性改變(即使是你沒用到的屬性),你的元件都會被迫重新渲染 (Re-render)。

為了避免這種效能浪費,我們應該告訴 Zustand:「我只關心這個特定的資料」。

基本語法 (Basic Syntax)

Selector 的使用語法非常直觀:將一個 callback function 傳入 hook 中。

// 語法: useStore(selector)
const value = useStore((state) => state.value);

Zustand 會自動比較 Selector 回傳的值:

  • 如果回傳值與上一次相同 (使用 === 嚴格相等比較),元件會重新渲染。
  • 如果回傳值不同,元件重新渲染。

常見模式

1. 單一屬性選取 (推薦 - Atomic Selectors)

最簡單也最推薦的做法,需要幾個屬性就呼叫幾次 Hook。這樣可以確保依賴最小化。

const bears = useStore((state) => state.bears);
const increasePopulation = useStore((state) => state.increasePopulation);
2. 這個寫法要注意! (Object Picking)

許多人習慣一次解構出多個屬性,像這樣:

// ⚠️ 警告:這會導致不必要的渲染!
const { bears, increasePopulation } = useStore((state) => ({
  bears: state.bears,
  increasePopulation: state.increasePopulation,
}));

為什麼這樣會有多餘渲染? 因為每次 Store 更新時,Selector 函式都會執行並回傳一個新的物件 ({ ... })。即使 bearsincrease 內容沒變,但因為新舊物件的記憶體位址不同 (=== 為 false),Zustand 會誤判資料有變動而觸發渲染。

3. 使用 useShallow (進階解法)

如果你真的很喜歡一次解構多個屬性,可以使用 useShallow 來避免上述的效能問題。

import { useShallow } from 'zustand/react/shallow';

// ✅ 安全:只會在 bears 或 increasePopulation 真正改變時才渲染
// useShallow 會對回傳的物件進行「淺層比較」,確認內容是否真的有變
const { bears, increasePopulation } = useStore(
  useShallow((state) => ({
    bears: state.bears,
    increasePopulation: state.increasePopulation,
  }))
);

實戰範例:優化 ProductCard

讓我們看看如何將 Selectors 應用在購物車範例中:

'use client';

import { useCartStore } from '@/store/useCartStore';

export function ProductCard({ product }) {
  // ✅ 正確:只選取 addItem 這個 action
  // 即使購物車內的 items 變多了,或者 isOpen 變成了 true,這個元件都「不會」重新渲染,因為 addItem 函數本身沒變。
  const addItem = useCartStore((state) => state.addItem);

  // ❌ 錯誤:沒有傳入 selector
  // 只要 store 內任何東西改變 (例如 items 增加),這個元件就會跟著重繪,造成浪費。
  // const { addItem } = useCartStore();

  return (
    <div className="border p-4">
      <h3>{product.name}</h3>
      <button onClick={() => addItem(product)}>加入購物車</button>
    </div>
  );
}

export function CartTotal() {
  // ✅ 選取 primitive type (數字)
  // 只有當 totalPrice 計算出的「結果數字」改變時,元件才會渲染
  const total = useCartStore((state) => state.totalPrice());

  return <div>總金額: ${total}</div>;
}

進階:資料持久化 (Persist) 與 Hydration Mismatch

在 Next.js 中使用 Zustand 的 persist middleware (將狀態存入 localStorage) 時,常會遇到 Hydration Mismatch 錯誤。這是因為伺服器端渲染 (SSR) 的 HTML (預設狀態) 與客戶端讀取 localStorage 後的 HTML (有資料的狀態) 不一致。

解決方案

我們需要建立一個自定義 Hook,確保只在 Client 端 mount 之後才讀取狀態。

1. 修改 Store 加入 persist

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

// ... (Interface 同上)

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      // ... (實作同上)
    }),
    {
      name: 'shopping-cart-storage', // localStorage 的 key
      storage: createJSONStorage(() => localStorage), // 預設就是 localStorage,可不寫
      skipHydration: true, // ✨ 關鍵:告訴 Zustand 不要自動 hydrate,我們手動處理
    }
  )
);

2. 建立 useHydratedStore Hook (通用解法)

為了在元件中安全使用,我們可以寫一個 hook 確保資料已經在前端準備好。但更簡單的做法是,如果你設定了 skipHydration: true,你需要手動呼叫 rehydrate,或者使用以下這個來自社群的 hook 模式來確保 UI 一致性:

// hooks/useStore.ts
import { useState, useEffect } from 'react';

// 這個 Hook 的用途是解決 Server/Client 狀態不一致的問題
const useStore = <T, F>(
  store: (callback: (state: T) => unknown) => unknown,
  callback: (state: T) => F
) => {
  const result = store(callback) as F;
  const [data, setData] = useState<F>();

  useEffect(() => {
    setData(result);
  }, [result]);

  return data;
};

export default useStore;

實際使用:

'use client';

import useStore from '@/hooks/useStore';
import { useCartStore } from '@/store/useCartStore';

export default function CartCounter() {
  // 使用包裝過的 useStore,在 Hydration 完成前會回傳 undefined/null
  const items = useStore(useCartStore, (state) => state.items);

  // loading 狀態或 fallback
  if (!items) return <div>Cart (0)</div>;

  return <div>Cart ({items.length})</div>;
}

小結

在 Next.js 建構應用程式時,請遵循以下狀態管理心法:

  1. 能放 URL 就放 URL (搜尋、篩選)。
  2. 能抓 Server 就抓 Server (商品列表、使用者資料)。
  3. UI 互動用 Local State (選單開關)。
  4. 跨元件共享用 Zustand (購物車、全域設定)。
  5. 注意 Hydration:使用 persist 時務必處理 SSR 與 Client 的同步問題。