表單與驗證
技術棧:react-hook-form + zod + @hookform/resolvers/zod + shadcn/ui
Form 元件(已安裝在 KODRA 脚手架中)。一個 zod schema 同時是型別、客戶端驗證和伺服器驗證的單一事實來源。
規則
- Schema 優先。 定義一個 zod schema;使用
z.infer推導 TypeScript 型別。將共享的 schema 放在src/lib/schemas/<name>.ts,以便伺服器路由可以匯入同一個檔案。 - 伺服器端也要驗證。 客戶端驗證是為了使用者體驗;伺服器路由、邊緣函式或資料庫約束才是安全邊界。使用相同的 schema 重新解析請求本體並回傳欄位錯誤。
- 每個欄位都要有可見的標籤、當格式不明顯時提供有用的說明,以及在欄位下方顯示行內錯誤訊息。
- 驗證時機:
mode: "onTouched"(在第一次失焦後驗證,之後在每次變更時驗證)。不要在使用者第一次輸入時就顯示錯誤。 - 提交按鈕在開始提交前保持啟用,提交開始後顯示待處理狀態並禁用,以防止重複提交。
- 成功時顯示發生了什麼事(toast 或行內訊息)並重設表單或導航。失敗時保留使用者的輸入,在表單附近顯示錯誤,並將焦點移至第一個無效的欄位(react-hook-form 預設會這麼做)。
- 使用正確的輸入類型:
type="email"、inputMode="numeric"、autoComplete標記(name、email、tel、street-address、postal-code、one-time-code、new-password),以便瀏覽器和手機提供協助。
標準表單範例
// src/lib/schemas/contact.ts
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().trim().min(2, "請輸入您的全名"),
email: z.string().trim().email("請輸入有效的電子郵件,例如 [email protected]"),
phone: z
.string()
.trim()
.regex(
/^0\d{8,9}$/,
"請輸入以 0 開頭的泰國電話號碼(9-10 位數字)",
)
.optional()
.or(z.literal("")),
topic: z.enum(["booking", "billing", "other"], {
required_error: "請選擇一個主題",
}),
message: z
.string()
.trim()
.min(10, "請再多說一點(至少 10 個字元)")
.max(2000),
consent: z.boolean().refine((v) => v, "請接受以繼續"),
});
export type ContactInput = z.infer<typeof contactSchema>;
// src/components/ContactForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { contactSchema, type ContactInput } from "@/lib/schemas/contact";
export function ContactForm() {
const form = useForm<ContactInput>({
resolver: zodResolver(contactSchema),
mode: "onTouched",
defaultValues: {
name: "",
email: "",
phone: "",
message: "",
consent: false,
},
});
async function onSubmit(values: ContactInput) {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (res.status === 422) {
const { fieldErrors } = (await res.json()) as {
fieldErrors: Partial<Record<keyof ContactInput, string>>;
};
for (const [field, message] of Object.entries(fieldErrors)) {
form.setError(field as keyof ContactInput, { type: "server", message });
}
return;
}
if (!res.ok) {
form.setError("root", {
message:
"我們無法傳送您的訊息。請檢查您的連線後重試。",
});
return;
}
toast.success("訊息已傳送。我們將在一個工作日內回覆。");
form.reset();
}
const { isSubmitting, errors } = form.formState;
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
noValidate
className="grid gap-5 sm:grid-cols-2"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>全名</FormLabel>
<FormControl>
<Input autoComplete="name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>電子郵件</FormLabel>
<FormControl>
<Input type="email" autoComplete="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topic"
render={({ field }) => (
<FormItem>
<FormLabel>主題</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="選擇一個主題" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="booking">預約</SelectItem>
<SelectItem value="billing">帳單</SelectItem>
<SelectItem value="other">其他</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="phone"
render={({ field }) => (
<FormItem>
<FormLabel>電話(選填)</FormLabel>
<FormControl>
<Input
type="tel"
inputMode="tel"
autoComplete="tel"
{...field}
/>
</FormControl>
<FormDescription>如果您偏好回電才填寫。</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem className="sm:col-span-2">
<FormLabel>訊息</FormLabel>
<FormControl>
<Textarea rows={5} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="consent"
render={({ field }) => (
<FormItem className="flex items-start gap-3 sm:col-span-2">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
我同意就此次請求與我聯繫
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
{errors.root && (
<p role="alert" className="text-sm text-destructive sm:col-span-2">
{errors.root.message}
</p>
)}
<div className="sm:col-span-2">
<Button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{isSubmitting && (
<Loader2
className="mr-2 h-4 w-4 animate-spin"
aria-hidden="true"
/>
)}
{isSubmitting ? "傳送中..." : "傳送訊息"}
</Button>
</div>
</form>
</Form>
);
}
注意事項:
noValidate會關閉原生瀏覽器氣泡,因此只會顯示 zod 的訊息。- 務必傳遞
defaultValues(空字串,而非undefined),以便輸入元件從第一次渲染開始就是受控的。 - 在 Select/RadioGroup 上使用
value={field.value}(而非defaultValue),這樣form.reset()才能清除它們。 - 當表單透過 TanStack Query mutation 提交時,請使用
mutation.isPending代替isSubmitting,並在onSubmit中呼叫mutateAsync,以便 react-hook-form 能夠等待它。
伺服器端驗證
使用相同的 schema 解析並回傳 422 狀態碼及欄位錯誤:
const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
return Response.json(
{ fieldErrors: parsed.error.flatten().fieldErrors },
{ status: 422 },
);
}
flatten().fieldErrors 會為每個欄位提供 string[];傳送第一個訊息(fieldErrors.email?.[0])或調整客戶端迴圈。資料庫約束(唯一電子郵件、外鍵)也應對應到欄位錯誤:捕獲唯一性違規並回傳 { fieldErrors: { email: "此電子郵件已註冊" } }。在僅有 RLS 存取的 Supabase 應用程式中,請在資料庫約束或邊緣函式中再次驗證;切勿依賴客戶端。
錯誤訊息風格
- 告訴使用者該做什麼,而不是告訴他們哪裡錯了:「請輸入今天之後的日期」,而不是「無效的日期」。
- 使用使用者的用語,並在行內顯示預期格式(「DD/MM/YYYY」)。
- 每次每個欄位只顯示一個訊息;不要重複顯示標籤。
- 使用應用程式語言撰寫訊息;zod 訊息是純字串,因此需傳遞翻譯過的文字(請參閱泰語本地化技能)。
狀態檢查清單
- 空狀態:標籤、有用的佔位符(範例,而非指示)。
- 輸入中:在第一次失焦之前不顯示錯誤。
- 無效:紅色邊框 + 欄位下方的訊息(
FormMessage),提交時焦點移至第一個錯誤。 - 提交中:按鈕禁用並顯示旋轉圖示和進度標籤;輸入欄位仍可讀取。
- 伺服器錯誤:在提交按鈕上方顯示帶有
role="alert"的訊息;保留輸入內容。 - 成功:顯示 toast 或行內確認訊息;重設表單或重新導向。
- 長表單的未儲存變更:在導航離開前發出警告(
formState.isDirty)。