【App 工程師的前端轉生術 04】邏輯層的核心:Custom Hooks 就是你的 ViewModel
01. 前言:尋找失落的 ViewModel
在 Part 3,我們用 Repository Pattern 搞定了資料層。現在,資料已經流進來了,但要放在哪裡?
作為 App 工程師,你的直覺告訴你:
- Android: 我需要一個繼承
androidx.lifecycle.ViewModel的 Class,裡面放StateFlow。 - iOS: 我需要一個繼承
ObservableObject的 Class,裡面放@Published屬性。
然而,你在 React 的文檔裡找不到 ViewModel 這個關鍵字。你只看到一堆 useState 和 useEffect 散落在 Component 裡。
這導致了很多「義大利麵代碼」:UI 渲染邏輯跟業務邏輯(表單驗證、按鈕狀態切換、錯誤處理)全部混在一起,就像你當年寫的 Massive View Controller。
今天這篇的核心觀念只有一個:在 React 中,Custom Hook (自定義 Hook) 就是你的 ViewModel。
02. 觀念對齊:Class vs. Function
在 Mobile World,ViewModel 是一個 物件 (Object),它活得比 View 久(Survives configuration changes)。 在 React World,ViewModel 是一個 函數 (Function),它隨著 View 的每一次渲染而執行。
雖然實作不同,但職責是完全一樣的:Input (Events) -> Process (Logic) -> Output (State)。
狀態管理的對照
| 概念 | Android (Kotlin) | iOS (Swift) | React (Hooks) |
|---|---|---|---|
| 保存狀態 | MutableStateFlow<T> | @Published var | useState<T> |
| 計算屬性 | val isValid: StateFlow<Boolean> | var isValid: Bool { ... } | const isValid = ... |
| 副作用/生命週期 | init { } / viewModelScope | init { } / Task | useEffect |
| 邏輯封裝單位 | class LoginViewModel | class LoginViewModel | function useLoginViewModel |
03. 反面教材:Massive Component
讓我們看一個典型的「髒」代碼。這是一個登入頁面,邏輯與 UI 糾纏不清。
// ❌ 典型的 Spaghetti Code
'use client'; // 代表這是 Client Component
export default function LoginPage() {
// 1. 狀態散落一地 (State Pollution)
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 2. 業務邏輯混在 View 裡 (Logic Leakage)
const handleLogin = async () => {
if (!email.includes('@')) {
setError('Email 格式錯誤');
return;
}
setIsLoading(true);
try {
await authRepository.login(email, password);
// 跳轉邏輯...
} catch (e) {
setError(e.message);
} finally {
setIsLoading(false);
}
};
// 3. UI 渲染 (View)
return (
<form onSubmit={(e) => { e.preventDefault(); handleLogin(); }}>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
{error && <span className="text-red-500">{error}</span>}
<button disabled={isLoading}>Login</button>
</form>
);
}
App 工程師視角:
這就是把所有 IBAction 和變數都寫在 ViewController 或 Activity 裡面的樣子。難以測試、難以閱讀、難以維護。
04. 解法:打造 useLoginViewModel
我們要將上述邏輯抽離。在 React 中,只要函數名稱以 use 開頭,它就是一個 Hook。
Step 1: 實作 ViewModel
// src/features/auth/view-models/useLoginViewModel.ts
import { useState } from 'react';
import { useRouter } from 'next/navigation'; // 類似 NavigationController
import { authRepository } from '@/infrastructure/repositories/authRepository';
export const useLoginViewModel = () => {
// --- State (如同 @Published / StateFlow) ---
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [uiState, setUiState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Next.js 的 Router Hook
const router = useRouter();
// --- Computed Properties (計算屬性) ---
// 這會在每次 render 時自動計算,不需額外 state
const isFormValid = email.includes('@') && password.length >= 6;
const isLoading = uiState === 'loading';
// --- Actions (如同 fun login() / func login()) ---
const login = async () => {
if (!isFormValid) return;
setUiState('loading');
setErrorMessage(null);
try {
// 呼叫我們在 Part 3 寫好的 Repository
await authRepository.login(email, password);
setUiState('success');
router.push('/dashboard'); // 導航跳轉
} catch (e: any) {
setUiState('error');
setErrorMessage(e.message || '登入失敗');
} finally {
if (uiState !== 'success') setUiState('idle');
}
};
// --- Return Interface (公開給 View 的介面) ---
// 這就像是 ViewModel 的 public properties
return {
// Inputs (Two-way binding helpers)
email,
setEmail,
password,
setPassword,
// Outputs (Read-only State)
isLoading,
errorMessage,
isFormValid,
// Actions
login,
};
};
Step 2: 重構 View Component
現在,我們的 View 變得極度乾淨,就像 SwiftUI 或 Jetpack Compose 的寫法:
// app/login/page.tsx
'use client';
import { useLoginViewModel } from '@/features/auth/view-models/useLoginViewModel';
export default function LoginPage() {
// ✨ Dependency Injection (Sort of)
// View 只需要知道 "有這個 ViewModel",不需要知道實作細節
const vm = useLoginViewModel();
return (
<div className="p-4">
<h1 className="text-2xl font-bold">登入</h1>
<div className="flex flex-col gap-4 mt-4">
{/* Input 不需要知道 state 怎麼存,只管綁定 */}
<input
placeholder="Email"
value={vm.email}
onChange={(e) => vm.setEmail(e.target.value)}
className="border p-2 rounded"
/>
<input
type="password"
placeholder="Password"
value={vm.password}
onChange={(e) => vm.setPassword(e.target.value)}
className="border p-2 rounded"
/>
{/* Error State */}
{vm.errorMessage && (
<div className="text-red-500 bg-red-50 p-2 rounded">
{vm.errorMessage}
</div>
)}
<button
onClick={vm.login}
disabled={!vm.isFormValid || vm.isLoading}
className="bg-blue-500 text-white p-2 rounded disabled:bg-gray-300"
>
{vm.isLoading ? '登入中...' : '登入'}
</button>
</div>
</div>
);
}
05. 關於依賴注入 (DI) 的預告
敏銳的 App 工程師可能發現了:useLoginViewModel 裡面直接 import 了 authRepository。這在單元測試時會很麻煩(很難 Mock)。
在 Android/iOS,我們習慣用 Hilt 或 Swinject 來注入。在 React Hook 中,我們可以用 Default Parameters 或 Context 來解決這個問題。這部分我們將在 Part 10: 測試與 DI 中詳細探討。
06. 小結:MVVM 完成體
到目前為止,我們已經建立了一個完整的 MVVM 架構:
- Model (Part 3): Zod Schemas & TypeScript Types。
- Repository (Part 3): 負責資料獲取與驗證。
- ViewModel (Part 4): Custom Hooks,負責狀態管理與業務邏輯。
- View (Part 1 & 4): React Components,只負責渲染
vm.state。
現在,你的前端程式碼已經具備了與 Native App 同等級的結構化程度。
但是,一個好的 App 不只有邏輯,還要有漂亮的 UI。iOS 有 SwiftUI Modifier,Android 有 Compose Modifier。React 呢?
在下一篇 【Part 5】,我們將探討 原子化元件設計 與 Tailwind CSS,讓你像堆積木一樣構建 UI,告別混亂的 CSS 檔案。

關於作者
Ken Huang
熱衷於嘗試各類新技術的軟體開發者,現階段主力為 Android / iOS 雙平台開發,同時持續深耕前端與後端技術,以成為全端工程師與軟體架構師為目標。
最廣為人知的代表作為 BePTT。開發哲學是「以做身體健康為前提」,致力於在工作與生活平衡的基礎上,打造出擁有絕佳使用體驗的軟體服務。
這裡是用於紀錄與分享開發經驗的空間,希望能透過我的實戰筆記,幫助開發者解決疑難雜症並提升效率。
系列文章目錄
- 【App 工程師的前端轉生術 02】觀念重塑:Next.js Runtime 與 App 生命週期
- 【App 工程師的前端轉生術 03】資料層的防護罩:用 Zod 與 Repository 打造原生級安全感
- 【App 工程師的前端轉生術 04】邏輯層的核心:Custom Hooks 就是你的 ViewModel (本文)
- 【App 工程師的前端轉生術 05】UI 架構篇:原子化元件設計與 Slot Pattern
- 【App 工程師的前端轉生術 06】樣式系統篇:Tailwind CSS 是你的 Modifier,不是傳統 CSS