本文由 SurveyJS 贊助 大多數 React 開發人員都有一個共同的心理模型,但從未大聲討論過。表單始終應該是元件。這意味著像這樣的堆疊:

用於本地狀態的 React Hook Form(最少的重新渲染、符合人體工學的欄位註冊、命令式互動)。 Zod 用於驗證(輸入正確性、邊界驗證、型別安全解析)。 後端的 React Query:提交、重試、快取、伺服器同步等。

對於絕大多數表單——登入畫面、設定頁面、CRUD 模式——這都非常有效。每個部分都各司其職,組成清晰,您可以繼續處理應用程式中真正使您的產品脫穎而出的部分。 但每隔一段時間,表單就會開始累積諸如依賴早期答案的可見性規則或透過三個欄位級聯的派生值等內容。甚至可能應該跳過或根據運行總計顯示整個頁面。 您可以使用 useWatch 和內聯分支處理第一個條件,這很好。然後是另一個。然後,您將使用 superRefine 來編碼 Zod 架構無法以正常方式表達的跨字段規則。然後,步驟導航開始洩漏業務邏輯。在某些時候,您查看自己建立的內容並意識到該表單不再是真正的 UI。這更多的是一個決策過程,而元件樹正是您儲存它的地方。 我認為這就是 React 中表單的思維模型崩潰的地方,這確實不是任何人的錯。 RHF + Zod 堆疊在其設計目的方面非常出色。問題是,我們傾向於繼續使用它,直到它的抽象與問題相匹配,因為替代方案需要完全不同的方式來思考形式。 本文就是關於這種替代方案的。為了說明這一點,我們將建立完全相同的多步驟表單兩次:

使用 React Hook Form + Zod 連接到 React Query 進行提交, 使用 SurveyJS,它將表單視為資料(一個簡單的 JSON 模式),而不是元件樹。

相同的要求、相同的條件邏輯、最後相同的 API 呼叫。然後,我們將準確地繪製出哪些內容被移動,哪些內容被保留,並提出一種實用的方法來決定您應該使用哪種模型以及何時使用。 我們正在建立的表單:

此表單將使用 4 步驟流程: 第 1 步:詳細信息

名字(必填), 電子郵件(必填,格式有效)。

第 2 步:訂購

單價, 數量, 稅率, 推導: 小計, 稅, 總計。

第 3 步:帳戶和回饋

您有帳戶嗎? (是/否) 如果是 → 使用者名稱 + 密碼,兩者都需要。 如果否 → 電子郵件已在步驟 1 中收集。

滿意度分數 (1–5) 如果 ≥ 4 → 問“你喜歡什麼?” 如果 ≤ 2 → 問“我們可以改進什麼?”

第四步:回顧

僅當總數 >= 100 時才出現 最終提交。

這並不極端。但這足以暴露架構差異。 第1部分:元件驅動(React Hook Form + Zod) 安裝 npm install react-hook-form zod @hookform/resolvers @tanstack/react-query

佐德模式 讓我們從 Zod 模式開始,因為這通常是建立表單形狀的地方。對於前兩個步驟 - 個人詳細資料和訂單輸入 - 一切都很簡單:所需的字串、最小值的數字和枚舉。當您嘗試表達條件規則時,有趣的部分就開始了。

從“zod”導入{z};

export const。 "否"]), 使用者名稱: z.string().可選(),密碼:z.string().可選(),滿意度:z.number().min(1).max(5),正向回饋:z.string().可選(),改進回授:z.string().可選(),}}).superefine(i>(i = )),}). if (!data.username) { ctx.addIssue({ code: "custom", path: ["username"], message: "Required" }); } if (!data.password || data.password.length < 6) { ctx.addIssue({ code: password.length < 6) { ctx.addIssue({ code:?

if (data.satisfaction >= 4 && !data.positiveFeedback) { ctx.addIssue({ code: "custom", path: ["positiveFeedback"], message: "請分享您喜歡的內容" }); }

if (data.satisfaction <= 2 && !data.improvementFeedback) { ctx.addIssue({ 程式碼: "自訂", 路徑:[“improvementFeedback”],訊息:“請告訴我們需要改進什麼”}); }});

匯出類型 FormData = z.infer;

請注意,即使使用者名稱和密碼是有條件必需的,它們也被鍵入為可選(),因為 Zod 的類型級架構描述了物件的形狀,而不是管理欄位何時重要的規則。 條件要求必須位於 superRefine 內部,它在驗證形狀後運行並可以存取完整物件。這種分離並不是缺陷;而是缺陷。這正是工具的設計目的:當跨領域邏輯無法在模式結構本身中表達時,superRefine 是它的用武之地。 這裡還值得注意的是這個模式沒有表達什麼。它沒有頁面的概念,沒有哪些欄位在哪一點可見的概念,也沒有導航的概念。所有這些都將生活在其他地方。 表單元件

從「react-hook-form」匯入{ useForm, useWatch };從「@hookform/resolvers/zod」匯入{ zodResolver };從「@tanstack/react-query」匯入{ useMutation };從「react」匯入{ State, useMemo };

const STEPS = ["詳情","訂單","帳戶","評論"];

type OrderPayload = FormData & { 小計:數量;稅:數量;總數:數量};

導出函數 RHFMultiStepForm() { const [step, setStep] = useState(0);

const 突變 = useMutation({ mutationFn:非同步(有效負載:OrderPayload)=> { const res = 等待 fetch("/api/orders", { 方法:“POST”, headers: { "Content-Type": "application/json" }, 內文:JSON.stringify(有效負載), }); if (!res.ok) throw new Error("提交失敗"); 返回 res.json(); }, });

const { 註冊、控制、handleSubmit、formState: { 錯誤 }, } = useForm({ 解析器: zodResolver(formSchema), defaultValues: { 價格: 0, 數量: 1, 稅率: 0.1, 滿意度: 3, hasAccount: "" const 價格 = useWatch({ 控制, 名稱: "價格" }); const 數量 = useWatch({ 控制, 名稱: "數量" }); const TaxRate = useWatch({ control, name: "taxRate" }); const hasAccount = hasst 滿意度 = satch"{trol, 相同 }"(Whas sst = smost.控制, 名稱: "滿意度" }); const 小計 = useMemo(() => (價格 ?? 0) * (數量 ?? 1), [價格, 數量]); const Tax = useMemo(() => 小計 * (taxRate ?? 0), [小計, TaxRate]); const 總計 = useMemo(); const onSubmit = (data: FormData) =>mutation.mutate({ ...data, 小計, 稅, 總計 }); const showSubmit = (步驟 === 2 && 總計 < 100) || (步驟 === 3 && 總計 >= 100)

return (

{step === 0 && ( <>

{step === 1 && ( <> 5% 15%

小計:{subtotal}
稅費:{tax}
總計:{total}
)}

{step === 2 && ( <>

{hasAccount === "是" && ( <> )}

{滿意度 >= 4 && (