簡而言之:我開發 Snapstate 是為了將商業邏輯從 React 元件移出,放入純 TypeScript 類別中。結果是:無須依賴 React 即可測試的狀態管理、僅負責渲染的元件,以及更清晰的 UI 與應用程式邏輯界線。
React 在渲染 UI 方面非常出色。但它並非承載應用程式其餘部分最理想的場所。
隨著時間推移,許多 React 程式碼庫會趨於相同的結構:在 useEffect 中進行資料擷取、在自訂 Hook 中處理商業規則、將衍生值分散在 useMemo 中,以及將變更隱藏在事件處理器裡。應用程式仍然能運作,但界線變得模糊。本應易於測試或重用的邏輯,卻與渲染時機、Hook 規則和元件生命週期耦合在一起。
我自己也寫過很多這類程式碼。Snapstate 的誕生源於我想要一個更清晰的界線:React 負責渲染,純 TypeScript 類別負責狀態與商業邏輯。
問題在於當應用程式邏輯也移入同一層級時。一個負責擷取資料、正規化資料、追蹤載入與錯誤、協調重試,並暴露變更方法的 Hook,就不再僅僅是 React 的考量。它變成了一個以 React 原生元素表達的應用程式服務。
這會帶來幾個可預見的成本。測試通常需要從渲染基礎設施開始,而非邏輯本身。重用性與 React 綁定,即使邏輯本身與 React 無關。理解行為意味著需要同時推理依賴陣列、掛載時機和重新渲染,以及商業規則。
我想要一個地方,讓該邏輯可以存在,而無需攜帶 React 一起。
我開發 Snapstate 並非因為生態系統是空的。我開發它是因為我想要一套不同的權衡。
Redux 提供了一個可預測的模型,但對於我所建構的應用程式類型來說,它也帶來了比我想要的更多的儀式感。狀態的故事很清晰。但商業邏輯的故事仍然傾向於分散在 reducers、thunks、middleware 和 selectors 之間。
Zustand 更輕量,我理解人們為何喜歡它。但對於較大的流程,我想要一些不那麼以 Hook 為中心的東西。一旦非同步操作、衍生值和跨狀態管理器的依賴開始堆積,我仍然希望邏輯讀起來像常規的應用程式程式碼。
MobX 大概最接近我想要的。它擁抱類別,並將許多邏輯排除在元件之外。我只是想要一些比隱式代理追蹤更明確、更少魔法的東西。
Snapstate 是我對這種折衷的嘗試:基於類別的狀態管理器、明確的更新,以及將 React 作為一個適配器,而非商業邏輯的所在地。
這是一個簡化的儀表板元件,採用了我經常見到的風格。身份驗證狀態來自 context,資料在 effect 中擷取,載入與錯誤狀態是局部的,衍生值與渲染並存:
這個元件沒有什麼不尋常,甚至沒有什麼錯誤。問題在於它同時承擔了太多職責:身份驗證感知、資料擷取、載入狀態、錯誤狀態、衍生資料、變更,以及渲染。這使得它更難測試,也更難重用,因為邏輯被黏合在元件生命週期上。
我想要的是將該行為移至狀態管理器,讓 React 只負責渲染。
這是一個儀表板狀態管理器,它從身份驗證中衍生出目前的 userId,載入儀表板資料,並負責變更:
DashboardStore 沒有全域實例 — 它被限定在元件生命週期內。視圖保持簡單。它接收純粹的 props 和回調,並且不知道資料來源、載入方式或身份驗證如何運作。
這不是魔法。這只是一個不同的界線。狀態管理器負責行為。React 渲染結果。這種分離使得程式碼更容易閱讀,因為元件不再需要解釋整個功能。
在測試方面,這種好處最為明顯。當邏輯存在於一個純粹的類別中時,你可以直接測試它。
無需渲染器。無需 providers。無需 act()。無需等待 UI 狀態來驗證商業規則。如果你願意,仍然可以使用 React Testing Library 測試視圖,但重要的是,行為不再被困在元件內部。
一旦我確定了這個界線,一些其他 API 也自然而然地出現了:用於螢幕生命週期的作用域狀態管理器、帶有 Zod 驗證的表單狀態管理器,以及用於 URL 應作為狀態模型一部分的螢幕的 URL 同步。
這些功能很重要,但它們是原始想法的結果,而非我開始這個專案的原因。原因更簡單:我希望商業邏輯存在於純 TypeScript 中,我希望 React 回歸成為渲染層。
Snapstate 在 GitHub 上開源,並可在 npm 上取得。我仍然認為它是 alpha 版本,因為我希望在邊緣情況上花更多時間,但核心 API 對我來說已經在生產環境中穩定使用了。
如果你覺得這個界線聽起來很有用,不妨看看文件或範例應用程式。