# ADR-001: 前端狀態管理採用 Zustand > **狀態**: Accepted > **日期**: 2026-03-20 (Gate 0 驗證完成) > **決策者**: 統帥 (CTO + CPO) > **關聯**: [docs/adr/ADR-004-state-management.md](../docs/adr/ADR-004-state-management.md) --- ## 摘要 AWOOOI 前端全面採用 **Zustand** 作為狀態管理工具,特別針對: - **Approval Multi-Sig 狀態機** - HITL 審批流程 - **SSE 即時串流** - Dashboard 主機監控 --- ## 背景 ### 問題陳述 AWOOOI 需要處理高頻率的狀態更新: | 場景 | 更新頻率 | 狀態類型 | |------|---------|---------| | Dashboard SSE | 每秒 | 4 主機 CPU/Memory | | Approval 簽核 | 事件驅動 | Multi-Sig 狀態機 | | OpenClaw 思考 | 串流 | AI 輸出 Token | 傳統的 Redux 在這種場景下過於笨重 (7KB + 大量 boilerplate)。 --- ## 決策 **採用 Zustand 作為唯一全域狀態管理工具** ### 核心實作 #### 1. Dashboard Store (SSE 整合) ```typescript // stores/dashboard.store.ts import { create } from 'zustand' interface DashboardState { hosts: HostStatus[] connectionStatus: 'connecting' | 'connected' | 'disconnected' | 'error' lastUpdate: Date | null // Actions connect: (apiUrl: string) => void disconnect: () => void updateHosts: (hosts: HostStatus[]) => void } export const useDashboardStore = create((set, get) => ({ hosts: [], connectionStatus: 'disconnected', lastUpdate: null, connect: (apiUrl) => { set({ connectionStatus: 'connecting' }) const eventSource = new EventSource(`${apiUrl}/api/v1/dashboard/stream`) eventSource.onopen = () => { set({ connectionStatus: 'connected' }) } eventSource.onmessage = (event) => { const data = JSON.parse(event.data) set({ hosts: data.hosts, lastUpdate: new Date() }) } eventSource.onerror = () => { set({ connectionStatus: 'error' }) } }, disconnect: () => { set({ connectionStatus: 'disconnected' }) }, updateHosts: (hosts) => set({ hosts, lastUpdate: new Date() }) })) // Selector hooks for fine-grained subscriptions export const useHosts = () => useDashboardStore((s) => s.hosts) export const useConnectionStatus = () => useDashboardStore((s) => s.connectionStatus) ``` #### 2. Approval Store (Multi-Sig 狀態機) ```typescript // stores/approval.store.ts import { create } from 'zustand' type SigningStatus = 'idle' | 'signing' | 'success' | 'error' interface ApprovalState { pendingApprovals: Approval[] selectedApproval: Approval | null signingStatus: SigningStatus // Actions fetchApprovals: () => Promise signApproval: (id: string, userId: string, role: string) => Promise rejectApproval: (id: string, reason: string) => Promise } export const useApprovalStore = create((set, get) => ({ pendingApprovals: [], selectedApproval: null, signingStatus: 'idle', fetchApprovals: async () => { const response = await fetch('/api/v1/approvals?status=pending') const data = await response.json() set({ pendingApprovals: data.items }) }, signApproval: async (id, userId, role) => { set({ signingStatus: 'signing' }) try { const response = await fetch(`/api/v1/approvals/${id}/approve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: userId, user_role: role }) }) if (response.status === 409) { // TOCTOU Conflict or Duplicate Signature throw new Error('Conflict') } const result = await response.json() // Update local state if (!result.needs_more) { // Remove from pending if fully approved set((s) => ({ pendingApprovals: s.pendingApprovals.filter(a => a.id !== id), signingStatus: 'success' })) } else { set({ signingStatus: 'success' }) } } catch (error) { set({ signingStatus: 'error' }) throw error } }, rejectApproval: async (id, reason) => { await fetch(`/api/v1/approvals/${id}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason }) }) set((s) => ({ pendingApprovals: s.pendingApprovals.filter(a => a.id !== id) })) } })) ``` --- ## 狀態機設計 ### Approval 生命週期 ``` ┌─────────────────────┐ │ pending │ └──────────┬──────────┘ │ ┌───────────────────┼───────────────────┐ │ │ │ ▼ ▼ ▼ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ approved │ │ rejected │ │ voided │ │ (簽章達閾值) │ │ (使用者拒絕) │ │ (TOCTOU衝突) │ └──────────────┘ └──────────────┘ └──────────────┘ ``` ### 風險矩陣 (簽章閾值) | Risk Level | 簽章數 | 條件 | |------------|--------|------| | low | 0 | 自動執行 | | medium | 1 | admin/devops | | high | 2 | 任二管理員 | | critical | 2 | 含 CTO 或 CISO | --- ## 理由 ### 為什麼選擇 Zustand | 特性 | Redux | Zustand | 優勢 | |------|-------|---------|------| | Bundle Size | ~7KB | ~1KB | **-86%** | | Boilerplate | 高 | 極低 | 開發效率 | | SSE 整合 | 需 middleware | 原生 | 簡單直接 | | TypeScript | 需額外設定 | 開箱即用 | DX 優異 | | Re-render | 需 selector | 內建 | 效能優化 | ### 為什麼不選擇 Redux 1. **過度工程** - AWOOOI 不需要 Redux 的 time-travel debugging 2. **Boilerplate** - 每個 action 需要 type/reducer/action creator 3. **Bundle** - 7KB 對於輕量 SaaS 是負擔 4. **SSE 整合** - 需要額外的 middleware 如 redux-saga ### 為什麼不選擇 Context API 1. **Re-render 問題** - Provider 下所有元件都會重繪 2. **不適合高頻更新** - SSE 每秒更新會造成效能問題 3. **缺乏 selector** - 無法細粒度訂閱 --- ## SSE 企業級模式 ### Buffer + Debounce ```typescript // 避免每個 SSE 事件都觸發 re-render const bufferRef = useRef([]) eventSource.onmessage = (event) => { bufferRef.current.push(JSON.parse(event.data)) } // 每 500ms 批次更新 setInterval(() => { if (bufferRef.current.length > 0) { set({ hosts: bufferRef.current }) bufferRef.current = [] } }, 500) ``` ### AbortController 清理 ```typescript useEffect(() => { const controller = new AbortController() connect(apiUrl) return () => { controller.abort() disconnect() } }, []) ``` ### 指數退避重連 ```typescript const reconnect = (attempt: number) => { const delay = Math.min(1000 * Math.pow(2, attempt), 30000) setTimeout(() => connect(), delay) } ``` --- ## 驗證結果 (Gate 0) | 測試項目 | 結果 | |---------|------| | Dashboard SSE 連線 | ✅ 穩定 | | 4 主機即時更新 | ✅ <100ms 延遲 | | Approval 簽核流程 | ✅ Multi-Sig 運作 | | TOCTOU 防護 | ✅ 409 正確處理 | | 記憶體洩漏 | ✅ 無 (AbortController) | --- ## 後果 ### 優點 - **極度輕量** - 不增加 bundle 負擔 - **高頻更新** - 完美處理 SSE 串流 - **簡單 API** - 降低學習曲線 - **TypeScript** - 完整型別推導 ### 缺點 - **生態較小** - 相比 Redux 社群資源較少 - **DevTools** - 功能不如 Redux DevTools 強大 ### 風險緩解 | 風險 | 緩解措施 | |------|---------| | Store 肥大化 | 強制 Slice Pattern | | 狀態同步錯誤 | 搭配 TanStack Query | --- ## 參考資料 - [Zustand 官方文檔](https://zustand-demo.pmnd.rs/) - [API 契約](../docs/api/approvals-contract.yaml) - [docs/adr/ADR-004](../docs/adr/ADR-004-state-management.md) - 詳細版本 --- *Gate 0 里程碑 - 2026-03-20*