【App 工程師的前端轉生術 07】全域狀態管理:為什麼 Singleton 在這裡行不通?(Zustand)
01. 前言:Singleton 的陷阱
在開發 Native App 時,當我們需要管理「登入的使用者」或「購物車內容」時,最直覺的做法是建立一個 Singleton:
iOS (Swift):
class UserManager {
static let shared = UserManager() // Singleton
var currentUser: User?
}
Android (Kotlin):
object UserManager { // Singleton
var currentUser: User? = null
}
如果你在 Next.js 裡這樣寫:
// ❌ 危險!絕對不要在 Next.js 這樣做
export const userManager = {
currentUser: null
};
發生什麼事?
Next.js 的伺服器端 (Node.js) 是 Long-running process。這個 userManager 物件會在記憶體中一直存在,並且被所有連進來的使用者請求共享。
- 使用者 A 登入,
userManager.currentUser變成了 "User A"。 - 使用者 B 進來,伺服器渲染頁面時讀取
userManager.currentUser,結果看到了 "User A" 的個資!
這就是 Cross-Request State Pollution (跨請求狀態污染)。 結論:在 SSR 環境下,我們不能依賴 Global Variable / Singleton。
02. 原生解法:React Context API 的侷限
React 內建的 Context API 透過 Provider pattern 解決了污染問題(每個 Request 有自己的 Component Tree,也就有自己的 Context)。
這看起來很像 App 的 Dependency Injection (環境變數注入):
- iOS: SwiftUI
.environmentObject() - Android: Jetpack Compose
CompositionLocalProvider
但它有嚴重的效能問題: 當 Context 更新時,所有使用這個 Context 的子元件都會強制 Re-render。這對於高頻更新的狀態(例如:購物車數量、滑動位置)來說,效能是災難級的。
Context 最適合的是 低頻更新 的全域設定(例如:切換語言、切換 Theme、登入狀態),而不是用來當作高頻讀寫的 Database。
03. 救星登場:Zustand
如果 Context 太慢,Redux 又太複雜(boilerplate 太多),那麼 Zustand 就是最適合 App 工程師的選擇。
它的心智模型非常簡單:它就像是一個 Global 的 Hook,但狀態儲存在 React Tree 之外。
安裝
npm install zustand
實作 Store (類似 Android 的 Repository / iOS 的 Service)
// src/features/cart/stores/useCartStore.ts
import { create } from 'zustand';
interface CartItem {
id: string;
name: string;
price: number;
}
interface CartState {
// State (資料)
items: CartItem[];
// Computed (雖然 Zustand 沒有 explicit computed,但可以在 selector 做)
// Actions (行為)
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
clearCart: () => void;
}
export const useCartStore = create<CartState>((set) => ({
items: [],
addItem: (item) => set((state) => ({
items: [...state.items, item]
})),
removeItem: (id) => set((state) => ({
items: state.items.filter(i => i.id !== id)
})),
clearCart: () => set({ items: [] }),
}));
04. 在 Component 中使用
Zustand 最強大的地方在於 Selectors (選擇器)。這就像是從龐大的 Store 中只訂閱你關心的部分。
情境 A:購物車按鈕 (只關心數量)
// components/CartButton.tsx
'use client';
import { useCartStore } from '@/features/cart/stores/useCartStore';
export function CartButton() {
// ✅ 只有當 items.length 改變時,這個元件才會 Re-render
// 如果購物車內容變了但數量沒變,這裡不會動!
const count = useCartStore((state) => state.items.length);
return <button>Cart ({count})</button>;
}
情境 B:商品列表頁 (只負責加商品,不關心內容)
// components/ProductItem.tsx
'use client';
import { useCartStore } from '@/features/cart/stores/useCartStore';
export function ProductItem({ product }) {
// ✅ 這裡只取出了 function,狀態改變完全不會觸發這個元件 Re-render
const addItem = useCartStore((state) => state.addItem);
return <button onClick={() => addItem(product)}>Add</button>;
}
App 工程師視角:
這是不是很像 RxJava/RxSwift 的 distinctUntilChanged() 或是 Coroutine Flow 的 map?Zustand 自動幫你處理了效能優化,你只需要專注在「我要拿什麼資料」。
05. 持久化 (Persistence):UserDefaults / SharedPreferences
在 App 中,我們會把簡單的設定存進 UserDefaults 或 SharedPreferences。
Zustand 內建了 middleware 來做這件事(存到 localStorage)。
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
export const useCartStore = create<CartState>()(
persist(
(set) => ({
items: [],
addItem: (item) => ...
}),
{
name: 'cart-storage', // localStorage 的 key
storage: createJSONStorage(() => localStorage), // 指定存到哪
}
)
);
就是這麼簡單。你的購物車現在重新整理頁面也不會消失了。
06. Client State vs Server State
最後要釐清一個觀念。 我們剛談的 Zustand,適合管理 Client State(UI 狀態、暫存資料)。
但對於從 API 拿回來的資料(例如使用者列表、商品詳情),App 工程師習慣用 Repository + Database 做快取。 在前端,我們通常不會把這些資料塞進 Zustand,而是使用專門的 Data Fetching Library,例如 React Query (TanStack Query)。
- Zustand: 管理「我的 App 現在的狀態」(Sidebar 打開沒?購物車有什麼?)。
- React Query: 管理「伺服器上的資料快取」(User Profile 是什麼?News List 是什麼?)。
我們不要把所有 API 回傳的東西都塞進 Global Store,那是 Redux 時代的舊思維。
07. 小結:狀態管理的最佳實踐
- Local State (
useState): 單一 Component 內部使用(例如:輸入框文字、展開/收合)。 - Context API: 依賴注入、低頻更新的全域設定(例如:Theme, User Auth Token)。
- Zustand: 高頻更新、跨頁面共享的 Client 狀態(例如:購物車)。
- React Query: API 資料快取與同步。
掌握了這個分層,你的 App 就不會遇到「按一個按鈕,整個網頁卡頓」的效能問題。
在下一篇 【Part 8】,我們要打破前後端的邊界。作為 App 工程師,你一定痛恨過後端 API 給的格式很爛。在 Next.js,你可以利用 BFF (Backend for Frontend) 和 Server Actions,自己寫一個中間層,把資料整理得漂漂亮亮再給 UI 用。

關於作者
Ken Huang
熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。
最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。
這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。
系列文章目錄
- 【App 工程師的前端轉生術 05】UI 架構篇:原子化元件設計與 Slot Pattern
- 【App 工程師的前端轉生術 06】樣式系統篇:Tailwind CSS 是你的 Modifier,不是傳統 CSS
- 【App 工程師的前端轉生術 07】全域狀態管理:為什麼 Singleton 在這裡行不通?(Zustand) (本文)
- 【App 工程師的前端轉生術 08】前端也能寫後端:BFF 模式與 Server Actions
- 【App 工程師的前端轉生術 09】效能優化篇:找出那該死的 Re-render