Community编程与开发github.com

ko-system/forms-validation

使用 react-hook-form、zod 和 shadcn/ui Form 构建无障碍表单,支持内联错误、待处理和禁用状态、成功反馈以及通过共享 schema 实现的服务端验证。

forms-validation 是什么?

该技能为开发者提供了一套完整的表单构建方案,基于 react-hook-form、zod 和 shadcn/ui 的 Form 组件,以类型安全的方式处理客户端和服务端验证。其核心原则是“Schema 优先”:定义一个 zod schema 作为类型、客户端验证和服务端验证的唯一数据源,并通过 z.infer 派生 TypeScript 类型,确保前后端类型一致。技能详细规定了验证时机(onTouched,即首次失焦后触发)、提交按钮状态管理(提交前保持可用,提交时显示加载并禁用)、成功/失败反馈机制(Toast 或内联消息)以及错误信息风格(告知用户应如何操作而非仅仅指出错误)。同时,它提供了完整的规范代码示例,涵盖表单组件搭建、服务端 422 错误处理(使用同一个 schema 进行 safeParse)以及 Supabase 等场景下的安全边界考量。支持的平台包括任何使用 react-hook-form 和 shadcn/ui 的 React 前端项目,特别适用于 Next.js 等全栈框架。

兼容平台~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. 成功时 显示操作结果(提示条或内联消息)并重置或导航。失败时 保留用户输入,在表单附近显示错误,并将焦点移到第一个无效字段(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 消息是纯字符串,因此需传递翻译后的文本(参见 thai-localization 技能)。

状态检查清单

  • 空状态:标签、帮助性占位文本(示例,非指令)。
  • 输入中:首次失焦前不显示错误。
  • 无效:红色边框 + 字段下方的消息(FormMessage),提交时焦点移到第一个错误。
  • 提交中:按钮禁用并带有旋转图标和进度标签;输入保持可读。
  • 服务端错误:提交按钮上方显示带有 role="alert" 的消息;保留输入。
  • 成功:提示条或内联确认;重置表单或重定向。
  • 长表单的未保存更改:离开前警告(formState.isDirty)。

Individual skills in this repo

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

相关技能