Community程式設計與開發github.com

ko-system/forms-validation

易於存取的表單,使用 react-hook-form、zod 和 shadcn/ui Form,涵蓋行內錯誤、待處理和禁用狀態、成功回饋以及透過共享 schema 進行的伺服器端驗證。

forms-validation 是什麼?

此技能專注於在 KODRA 或類似專案中建立易於存取、穩健且對開發者友善的表單。它強制執行「schema-first」的開發流程,使用單一的 Zod schema 作為 TypeScript 類型、客戶端驗證和伺服器端驗證的單一事實來源。主要規則包括:客戶端驗證僅用於提升使用者體驗,伺服器端必須使用相同 schema 重新驗證以確保安全;每個欄位都需有可見的標籤、說明與行內錯誤提示;驗證時機設為 `onTouched`,避免使用者首次輸入時就立即顯示錯誤;提交按鈕在送出期間應顯示載入狀態並禁用,以防止重複提交;成功時顯示提示訊息並重設表單,失敗時則保留使用者輸入並顯示錯誤。此技能支援 react-hook-form、zod、shadcn/ui 等技術棧,並提供表單範例、伺服器驗證的實作方式、錯誤訊息撰寫風格以及完整的狀態檢查清單。

相容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/ko-system/kodra-skills/tree/HEAD/skills/forms-validation

Installed? Explore more 程式設計與開發 skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

表單與驗證

技術棧:react-hook-form + zod + @hookform/resolvers/zod + shadcn/ui Form 元件(已安裝在 KODRA 脚手架中)。一個 zod schema 同時是型別、客戶端驗證和伺服器驗證的單一事實來源。

規則

  1. Schema 優先。 定義一個 zod schema;使用 z.infer 推導 TypeScript 型別。將共享的 schema 放在 src/lib/schemas/<name>.ts,以便伺服器路由可以匯入同一個檔案。
  2. 伺服器端也要驗證。 客戶端驗證是為了使用者體驗;伺服器路由、邊緣函式或資料庫約束才是安全邊界。使用相同的 schema 重新解析請求本體並回傳欄位錯誤。
  3. 每個欄位都要有可見的標籤、當格式不明顯時提供有用的說明,以及在欄位下方顯示行內錯誤訊息。
  4. 驗證時機: mode: "onTouched"(在第一次失焦後驗證,之後在每次變更時驗證)。不要在使用者第一次輸入時就顯示錯誤。
  5. 提交按鈕在開始提交前保持啟用,提交開始後顯示待處理狀態並禁用,以防止重複提交。
  6. 成功時顯示發生了什麼事(toast 或行內訊息)並重設表單或導航。失敗時保留使用者的輸入,在表單附近顯示錯誤,並將焦點移至第一個無效的欄位(react-hook-form 預設會這麼做)。
  7. 使用正確的輸入類型: 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)。

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

相關技能