跳至主要内容

ADR-008: 表單部分更新策略

狀態

已接受 (2025-12-21)

背景 (Context)

問題陳述

目前表單編輯提交時,會傳送完整的表單資料到後端,無論使用者實際修改了哪些欄位。這造成以下問題:

  1. 審計不精確 - 無法準確記錄「使用者改了什麼」
  2. 資料一致性風險 - 完整替換可能覆蓋其他來源的變更
  3. 意圖不明確 - API 無法區分「使用者主動設為此值」與「使用者未修改」
  4. 擴展性問題 - 隨著領域資料(如訂單)欄位增加,傳輸冗餘資料

限制條件

  • 需與 React Hook Form 整合
  • 需避免不必要的重新渲染
  • 需處理巢狀物件(如訂單項目陣列)
  • 需區分「未修改」與「清空欄位」

關鍵利害關係人

  • 前端團隊
  • 後端團隊
  • 資安/稽核團隊

決策 (Decision)

選擇的方案

採用 React Hook Form 的 dirtyFields 追蹤變更欄位,提交時只傳送實際修改的資料。

實施細節

1. useForm 宣告規範:兩種有效模式

React Hook Form v7 的 formState 是 Proxy 物件,使用「讀取時訂閱」機制。根據使用場景,有兩種有效的模式:

模式 A:訂閱模式(需要 UI 即時顯示 dirty 狀態時使用)
// ✅ 在 render 時解構,訂閱 dirtyFields 變化
const {
control,
handleSubmit,
formState: { dirtyFields, errors }
} = useForm<FormType>()

// 在 handler 中使用解構出來的變數
const submitHandler = (data: FormType) => {
const changes = extractChanges(data, dirtyFields) // ✅ 使用解構的變數
await service.update(id, changes)
}

特性

  • ✅ 可在 UI 即時顯示「表單已修改」等狀態
  • ⚠️ 每當任何欄位的 dirty 狀態改變時,元件會重新渲染
模式 B:延遲讀取模式(只在提交時需要 dirtyFields 時使用,效能更佳)

⚠️ 重要限制:模式 B 必須使用 reset() 設定初始值,不能使用 defaultValues

// ✅ 保留 formState 物件,不解構 dirtyFields
const {
control,
handleSubmit,
reset,
formState,
} = useForm<FormType>({
resolver: validatorResolver(schema),
// ⚠️ 不設定 defaultValues,改用 reset()
})

// 使用 reset() 設定初始值(必須在 useEffect 中)
useEffect(() => {
reset({
name: defaultValues?.name ?? null,
// ... 其他欄位
})
}, [reset]) // 依賴 key 重新創建來更新

// 在 handler 中讀取當下的 dirtyFields 狀態
const submitHandler = (data: FormType) => {
const changes = extractChanges(data, formState.dirtyFields) // ✅ 讀取當下狀態
await service.update(id, changes)
}

特性

  • ✅ 避免 dirty flag 變化時的不必要重新渲染(適合有多個 checkbox/toggle 等頻繁變化的表單)
  • ❌ 無法在 UI 即時顯示 dirty 狀態
  • ⚠️ 必須使用 reset() 而非 defaultValues(否則 Proxy 訂閱機制無法正常運作)
❌ 錯誤:模式 B 使用 defaultValues
// ❌ 錯誤:模式 B 配合 defaultValues 會導致 dirtyFields 永遠為空
const {
formState, // 保留 formState(模式 B)
} = useForm<FormType>({
defaultValues: { name: 'test' } // ❌ 使用 defaultValues
})

const submitHandler = (data: FormType) => {
console.log(formState.dirtyFields) // {} - 永遠是空的!
}

原因:當使用 defaultValues 同步初始化時,Proxy 訂閱機制尚未完全啟用。使用 reset() 會觸發異步更新,讓 Proxy 正確追蹤變更。

❌ 錯誤:混用兩種模式
// ❌ 錯誤:解構後又嘗試使用 formState
const {
formState: { dirtyFields, errors } // 解構後沒有 formState 變數
} = useForm<FormType>()

const submitHandler = (data: FormType) => {
// ReferenceError: formState is not defined
const changes = extractChanges(data, formState.dirtyFields)
}
如何選擇?
場景推薦模式初始值設定
需要顯示「有未儲存變更」提示模式 A(訂閱模式)defaultValuesreset()
表單有多個 checkbox/toggle模式 B(延遲讀取模式)必須用 reset()
只在提交時需要 dirtyFields模式 B(延遲讀取模式)必須用 reset()
一般表單兩者皆可模式 A 用 defaultValues,模式 B 用 reset()

2. getValues() 使用規範

// ✅ 正確:在 event handler 中使用
const handleDebug = () => {
const data = getValues()
console.log(data)
}

// ❌ 錯誤:在 component body 直接調用
function MyForm() {
const data = getValues() // 每次 values 變化都會觸發重新渲染
return <div>{data.name}</div>
}

3. 提取變更欄位的標準模式

// 模式 A(訂閱模式):使用解構的 dirtyFields
const submitHandler: SubmitHandler<FormType> = async (data) => {
const changedFields = Object.keys(dirtyFields)
const changes = changedFields.reduce((acc, field) => {
acc[field] = data[field]
return acc
}, {} as Partial<FormType>)

await service.update(id, changes)
reset(data)
}

// 模式 B(延遲讀取模式):使用 formState.dirtyFields
const submitHandler: SubmitHandler<FormType> = async (data) => {
const changedFields = Object.keys(formState.dirtyFields)
const changes = changedFields.reduce((acc, field) => {
acc[field] = data[field]
return acc
}, {} as Partial<FormType>)

await service.update(id, changes)
reset(data)
}

4. 載入資料時使用 reset()

useEffect(() => {
const fetchData = async () => {
const data = await service.get(id)
reset(data) // 設置初始值並重設 dirtyFields
}
void fetchData()
}, [id, reset])

5. 巢狀物件的處理

對於陣列欄位(如 items),建議整體替換:

const changes = changedFields.reduce((acc, field) => {
if (field === 'items') {
// 陣列欄位:整體替換
acc.items = data.items
} else {
acc[field] = data[field]
}
return acc
}, {} as Partial<FormType>)

6. 處理欄位清空

當使用者主動清空欄位時,需明確傳送 null

const changes = changedFields.reduce((acc, field) => {
const value = data[field]
// 明確傳送 null,區分「未修改」與「清空」
acc[field] = value === '' ? null : value
return acc
}, {} as Partial<FormType>)

7. handleSubmit 必須提供錯誤回調

問題handleSubmit 驗證失敗時不會拋出錯誤,只是不調用 onSubmit,導致靜默失敗。

// ❌ 錯誤:驗證失敗時沒有任何反饋
void handleSubmit(onSubmit)(e)

// ✅ 正確:始終提供錯誤回調
void handleSubmit(
onSubmit,
(errors) => {
logger.warn('Form validation failed:', errors)
}
)(e)

注意formState.errors 在 submit 之前可能是空的,真正的驗證發生在 handleSubmit 內部。

8. 日期欄位轉換策略

API 返回的日期可能是 Date 物件或 ISO 時間戳字串,但表單期望 YYYY-MM-DD 格式。

必須在資料轉換函數中統一處理所有日期欄位,包括巢狀物件中的日期:

/**
* 將日期轉換為表單期望的 YYYY-MM-DD 格式
*/
function toDateString(value: Date | string | undefined): string {
if (!value) return ''
if (value instanceof Date) return value.toISOString().split('T')[0]
if (typeof value === 'string' && value.includes('T')) return value.split('T')[0]
return value
}

// 使用範例:處理巢狀物件中的日期
const validImportantDates = (customer.importantDates ?? [])
.filter((item) => item.date && item.label)
.map((item) => ({
...item,
date: toDateString(item.date),
}))

檢查清單

  • 頂層日期欄位(如 birthdaydeliveryDate
  • 陣列中物件的日期欄位(如 importantDates[].date
  • 巢狀物件的日期欄位

考慮的替代方案 (Alternatives Considered)

方案 A: 完整替換 (PUT Semantics)

描述: 每次提交傳送完整表單資料。

優點:

  • 實作簡單
  • 無需追蹤變更

缺點:

  • 無法精確審計
  • 可能覆蓋並發修改
  • 傳輸冗餘資料

為何未採用: 不符合領域資料的審計需求。

方案 B: 手動追蹤原始值

描述: 自行維護原始值的 ref,提交時計算差異。

優點:

  • 完全控制差異計算邏輯

缺點:

  • 需手動維護狀態
  • 容易出錯
  • 與 React Hook Form 功能重複

為何未採用: React Hook Form 已提供 dirtyFields,無需重複造輪子。


決策後果 (Consequences)

正面影響 (Positive)

  • 精確審計 - 可準確記錄使用者修改的欄位
  • 減少衝突 - 降低覆蓋其他來源變更的風險
  • 意圖明確 - API 可明確知道哪些欄位是使用者主動修改
  • 效能優化 - 減少不必要的資料傳輸
  • 統一規範 - 所有編輯功能採用一致的模式

負面影響 (Negative)

  • ⚠️ 實作複雜度增加 - 需理解 dirtyFields 機制
    • 緩解:提供標準模式和範例程式碼
  • ⚠️ 後端需配合 - API 需支援部分更新 (PATCH)
    • 緩解:統一使用 PATCH 方法

風險 (Risks)

  • 🚨 巢狀物件處理複雜(機率: 中,影響: 中)
    • 緩解:陣列欄位採用整體替換策略
  • 🚨 驗證失敗靜默失敗(機率: 高,影響: 高)
    • 緩解:handleSubmit 必須提供錯誤回調
  • 🚨 日期格式不一致(機率: 中,影響: 中)
    • 緩解:資料轉換函數統一處理所有日期欄位
  • 🚨 模式 B 配合 defaultValues 導致 dirtyFields 失效(機率: 高,影響: 高)
    • 緩解:模式 B 必須使用 reset() 在 useEffect 中設定初始值
  • 🚨 模式 B 第一次 render 時 watch() 返回 undefined(機率: 高,影響: 高)
    • 原因:reset()useEffect 中執行,第一次 render 時尚未執行
    • 緩解:對 watch() 返回值提供 fallback,例如 watch('items') ?? []

需要注意的事項 (Notes)

  • 📌 選擇一種 formState 存取模式並保持一致(參見第 1 節)
  • 📌 絕對不要混用兩種模式(解構 formState: { dirtyFields } 後又使用 formState.dirtyFields
  • 📌 模式 B 必須使用 reset() 設定初始值,不能使用 defaultValues(否則 dirtyFields 無法追蹤)
  • 📌 模式 B 使用 watch() 時必須提供 fallback,例如 watch('items') ?? [](第一次 render 時 reset 尚未執行)
  • 📌 不要在 component body 直接調用 getValues()
  • 📌 成功提交後必須呼叫 reset() 重設 dirtyFields
  • 📌 HTTP 方法統一使用 PATCH
  • 📌 handleSubmit 必須提供錯誤回調,避免驗證失敗時靜默失敗
  • 📌 資料轉換函數必須處理所有日期欄位,包括巢狀物件中的日期

影響範圍 (Impact)

受影響的系統/模組

  • src/applets/order-applet/order-editor.tsx - 需重構
  • src/applets/customer-applet/customer-editor.tsx - 需重構
  • src/services/sales/order-service.ts - 已使用 PUT,需改為 PATCH
  • 未來所有編輯功能

需要的變更

  1. 重構現有編輯器,採用 dirtyFields 模式
  2. 統一 HTTP 方法為 PATCH
  3. 後端 API 支援部分更新

參考資料 (References)


後續行動 (Follow-up Actions)

  • 重構 OrderEditor 採用部分更新模式
  • 重構 CustomerEditor 採用部分更新模式
  • 統一 API HTTP 方法為 PATCH(訂單已完成)
  • 更新 API 規格文件(訂單已完成)

最後更新: 2025-12-21 (補充:模式 B 必須使用 reset() 設定初始值) 決策者: Development Team