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)、重新整理不丟失、瀏覽器上一頁/下一頁原生支援。
- 實作:使用
useSearchParams與router.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 社群的首選,原因如下:
- 極簡 (Minimalist):API 非常簡單,沒有 Redux 的 boilerplate (樣板程式碼)。
- Hook-based:完全符合 React Hooks 的使用習慣。
- 效能優化:內建 Selectors,只有當你訂閱的特定狀態改變時,元件才會重新渲染 (Re-render)。
- 彈性:可以在 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 函式都會執行並回傳一個新的物件 ({ ... })。即使 bears 和 increase 內容沒變,但因為新舊物件的記憶體位址不同 (=== 為 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 建構應用程式時,請遵循以下狀態管理心法:
- 能放 URL 就放 URL (搜尋、篩選)。
- 能抓 Server 就抓 Server (商品列表、使用者資料)。
- UI 互動用 Local State (選單開關)。
- 跨元件共享用 Zustand (購物車、全域設定)。
- 注意 Hydration:使用
persist時務必處理 SSR 與 Client 的同步問題。