iT邦幫忙

2026 iThome 鐵人賽

DAY 14
0
Vibe Coding

Vibe Mode 開啟:30 天用 AI 打造網頁,邊做邊學 JavaScript系列 第 14 篇

# Day 14:複雜 Form 表單開發:AI 處理表單驗證、動態欄位與狀態控制

  • 分享至 

  • xImage
  •  

今日目標

在昨天建立好 App 的響應式 Layout 與 Navigation 後,今天我們要迎來前端開發最核心、也最容易讓人頭痛的魔王關卡——表單(Form)處理!

在我們的「Smart Finance Tracker」中,使用者需要新增交易紀錄。這絕非只是一個簡單的輸入框,而是包含以下條件的複雜表單:

  1. 動態連動欄位:切換「收入」或「支出」時,分類選單(Category)必須動態切換選項。
  2. 嚴謹的型態驗證:金額必須為正數、日期不可為空、備註有字數上限。
  3. 極致的 UX 體驗:包含即時錯誤提示、自動聚焦(Focus)以及防止重複送出的 Loading 狀態。

今天我們將展示如何運用 AI 結合 React Hook Form 與 Zod,快速構建出兼具型態安全與流暢體驗的表單!


Human vs. AI 實戰記錄

1. 給 AI 的指令 (Prompt)

為了避免 AI 給出過時的原生 useState 控管方式,我們必須指定現代 React 生態系的黃金組合:React Hook Form + @hookform/resolvers/zod + shadcn/ui。

你現在是一位 senior 前端工程師。請幫我建立「新增交易」的表單元件 (`<AddTransactionForm />`)。

需求如下:
1. 使用 `react-hookform` 搭配 `zod` 進行型態驗證。
2. Zod Schema 規範:
   - type: 必須是 'INCOME' 或 'EXPENSE'
   - amount: 數字型態,必須大於 0,預設 0
   - category: 必填字串
   - date: 日期型態,預設今天
   - note: 選填字串,上限 50 字
3. UI 需求 (使用 shadcn/ui 元件):
   - type 選擇器使用 Segmented Control 或 Radio Group
   - 當 type 切換時,category 下拉選單要動態更換清單(支出:外食、交通、購物;收入:薪水、投資、副業)
   - 包含 Submit 按鈕,送出時顯示 isSubmitting 的 Loading 狀態


2. AI 產出的結果 (Code)

AI 給出了一份初稿:

// components/AddTransactionForm.tsx
"use client"

import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

const formSchema = z.object({
  type: z.enum(["INCOME", "EXPENSE"]),
  amount: z.number().min(1, "金額必須大於 0"),
  category: z.string().min(1, "請選擇分類"),
  date: z.date(),
  note: z.string().max(50, "備註不能超過 50 字").optional(),
})

export function AddTransactionForm() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      type: "EXPENSE",
      amount: 0,
      category: "",
      note: "",
    },
  })

  function onSubmit(values: z.infer<typeof formSchema>) {
    console.log(values)
  }

  return (
    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
      <input type="number" {...form.register("amount")} />
      {form.formState.errors.amount && (
        <p className="text-red-500">{form.formState.errors.amount.message}</p>
      )}
      <Button type="submit">新增交易</Button>
    </form>
  )
}


3. 修正與翻車紀錄 (Debug Experience)

這份程式碼看似完成了基礎結構,但在實務上隱藏了三個讓人抓狂的翻車點!

翻車點一:HTML Input type="number" 回傳字串導致 Zod 驗證爆炸

原生 <input type="number" /> 透過 React Hook Form 的 register 綁定時,傳入 onSubmit 的數值實際上是 string 型態(例如 "100"),這會直接導致 Zod 的 z.number() 驗證失敗,爆出 Expected number, received string 錯誤!

翻車點二:連動欄位 State 未清空 (Stale State)

當使用者原本選擇「支出 ➔ 外食」,突然切換為「收入」時,category 欄位依然保留著「外食」的舊值,這會導致無效資料被寫入資料庫。

💥 翻車點三:丟棄了 shadcn/ui 的 <Form/> 抽象組件

AI 放著 shadcn/ui 封裝好的 <FormField> 與 <FormMessage> 不用,跑回頭寫原生的 <p className="text-red-500">,這破壞了我們在 Day 12 建立的設計系統一致性。

修復!

我們進行了以下針對性調整:

  1. **使用 valueAsNumber: true 或 z.coerce.number()**:強制將 Input 輸入轉換為 JavaScript Number 型態。
  2. 加入 useWatch 監聽與 setValue 重置機制:當 type 改變時,自動將 category 重置為該類別的第一個預設選項。
  3. 改用 shadcn/ui <Form> 體系:提升無障礙(Accessibility)與錯誤訊息呈現品質。

修正後的程式碼:

// components/AddTransactionForm.tsx
"use client"

import { useEffect } from "react"
import { useForm, useWatch } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"

const CATEGORIES = {
  EXPENSE: ["外食", "交通", "購物", "娛樂"],
  INCOME: ["薪水", "投資", "副業"],
}

const formSchema = z.object({
  type: z.enum(["INCOME", "EXPENSE"]),
  // 修正點 1:使用 z.coerce 自動轉換 string 為 number
  amount: z.coerce.number().gt(0, "金額必須大於 0 元"),
  category: z.string().min(1, "請選擇分類"),
  note: z.string().max(50, "備註上限 50 字").optional(),
})

export function AddTransactionForm() {
  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: { type: "EXPENSE", amount: 0, category: "外食", note: "" },
  })

  // 監聽 type 欄位變化
  const currentType = useWatch({ control: form.control, name: "type" })

  // 修正點 2:當 type 切換時,自動將 category 重置為合法的第一個選項
  useEffect(() => {
    form.setValue("category", CATEGORIES[currentType][0])
  }, [currentType, form])

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit((data) => console.log(data))} className="space-y-4">
        <FormField
          control={form.control}
          name="amount"
          render={({ field }) => (
            <FormItem>
              <FormLabel>金額</FormLabel>
              <FormControl>
                <Input type="number" placeholder="0" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        {/* 動態選單與 Submit 按鈕... */}
      </form>
    </Form>
  )
}


JavaScript / AI 學習小結

  1. DOM Input Value 的原生特性(Type Coercion):
    HTML 表單輸入框永遠傳回字串(String)。在處理數字表單時,理解 z.coerce.number() 或 valueAsNumber 是避免 React Hook Form 與 Zod 型態衝突的關鍵技法。
  2. 副作用與連動狀態的控制:
    AI 很容易忘記處理「欄位之間的相依性(Field Dependencies)」。工程師需要明確指示 useWatch 與 useEffect 的搭配時機,確保資料模型的狀態機(State Machine)始終處於合法的狀態。

上一篇
# Day 13:響應式 Layout 開發:指示 AI 生成 App 核心頁面外框與 Navigation
下一篇
# Day 15 - 前後端 API 串接:讓 AI 寫 Client-side 資料獲取(TanStack Query / SWR)
系列文
Vibe Mode 開啟:30 天用 AI 打造網頁,邊做邊學 JavaScript 共 15 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言