beio Logobeio

【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 物件會在記憶體中一直存在,並且被所有連進來的使用者請求共享

  1. 使用者 A 登入,userManager.currentUser 變成了 "User A"。
  2. 使用者 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 中,我們會把簡單的設定存進 UserDefaultsSharedPreferences。 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. 小結:狀態管理的最佳實踐

  1. Local State (useState): 單一 Component 內部使用(例如:輸入框文字、展開/收合)。
  2. Context API: 依賴注入、低頻更新的全域設定(例如:Theme, User Auth Token)。
  3. Zustand: 高頻更新、跨頁面共享的 Client 狀態(例如:購物車)。
  4. React Query: API 資料快取與同步。

掌握了這個分層,你的 App 就不會遇到「按一個按鈕,整個網頁卡頓」的效能問題。

在下一篇 【Part 8】,我們要打破前後端的邊界。作為 App 工程師,你一定痛恨過後端 API 給的格式很爛。在 Next.js,你可以利用 BFF (Backend for Frontend)Server Actions,自己寫一個中間層,把資料整理得漂漂亮亮再給 UI 用。


Ken Huang

關於作者

Ken Huang

熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。

最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。

這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。

Android APP DevelopmentiOS APP DevelopmentBePTT CreatorFull Stack Learner