Community코딩 & 개발github.com

Forms/Validation (한국어)

react-hook-form, zod, shadcn/ui Form을 사용한 접근성 높은 폼으로, 인라인 오류, 보류/비활성화 상태, 성공 피드백 및 공유 스키마를 통한 서버 측 유효성 검사를 지원합니다.

Validation (한국어)란 무엇인가요?

이 스킬은 react-hook-form과 zod를 기반으로 한 접근성과 사용자 경험이 뛰어난 폼 시스템을 구축하는 방법을 제공합니다. 스키마를 단일 진실 공급원(source of truth)으로 삼아 클라이언트와 서버에서 동일한 유효성 검사를 실행할 수 있어, 유지보수성과 보안이 크게 향상됩니다. 주요 기능으로는 `onTouched` 모드로 첫 블러 이후에만 검증하여 사용자 경험을 개선하고, 제출 중에는 버튼을 비활성화하여 이중 제출을 방지합니다. 서버 에러 발생 시 필드별 오류 메시지를 그대로 사용자에게 전달하며, 성공 시 토스트/인라인 메시지를 표시하고 폼을 리셋합니다. 모든 필드에는 label과 접근성 속성(autoComplete, inputMode 등)이 포함되어 스크린 리더와 모바일 환경에서도 최적의 경험을 제공합니다. 코드 예제는 KODRA 스캐폴드에 기본 설치된 shadcn/ui Form 컴포넌트를 기반으로 하며, TanStack Query와의 통합도 고려되어 있습니다. 서버 측에서는 동일한 zod 스키마로 요청 본문을 검증하고 422 상태 코드와 함께 `flatten().fieldErrors`를 반환합니다. 데이터베이스 유니크 제약 조건 위반도 필드 오류로 매핑할 수 있어, Supabase 환경에서도 RLS만으로는 부족할 경우 엣지 함수나 DB 제약 조건을 통해 추가 검증을 권장합니다. 오류 메시지는 '무엇이 잘못되었는지' 대신 '무엇을 해야 하는지'를 안내하며, 사용자 용어와 예상 형식을 인라인으로 표시합니다. 접근성 체크리스트(빈 상태, 입력 중, 잘못된 입력, 제출 중, 서버 오류, 성공, 변경 사항 미저장 경고)를 제공하여 완전한 폼을 구현할 수 있도록 돕습니다.

지원 대상~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에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

폼과 유효성 검사

스택: react-hook-form + zod + @hookform/resolvers/zod + shadcn/ui Form 컴포넌트(KODRA 스캐폴드에 이미 설치됨). 하나의 zod 스키마가 타입, 클라이언트 유효성 검사 및 서버 유효성 검사의 단일 진실 공급원입니다.

규칙

  1. 스키마 우선. zod 스키마를 정의하고 z.infer로 TypeScript 타입을 도출합니다. 공유 스키마는 src/lib/schemas/<name>.ts에 배치하여 서버 라우트가 동일한 파일을 가져올 수 있도록 합니다.
  2. 서버에서도 유효성 검사. 클라이언트 유효성 검사는 UX를 위한 것일 뿐이며, 서버 라우트, 엣지 함수 또는 데이터베이스 제약 조건이 보안 경계입니다. 동일한 스키마로 본문을 다시 파싱하고 필드 오류를 반환합니다.
  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, "Enter your full name"),
  email: z.string().trim().email("Enter a valid email, like [email protected]"),
  phone: z
    .string()
    .trim()
    .regex(
      /^0\d{8,9}$/,
      "Enter a Thai phone number starting with 0 (9-10 digits)",
    )
    .optional()
    .or(z.literal("")),
  topic: z.enum(["booking", "billing", "other"], {
    required_error: "Choose a topic",
  }),
  message: z
    .string()
    .trim()
    .min(10, "Tell us a bit more (at least 10 characters)")
    .max(2000),
  consent: z.boolean().refine((v) => v, "Please accept to continue"),
});
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:
          "We couldn't send your message. Check your connection and try again.",
      });
      return;
    }
    toast.success("Message sent. We'll reply within one business day.");
    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>Full name</FormLabel>
              <FormControl>
                <Input autoComplete="name" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" autoComplete="email" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="topic"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Topic</FormLabel>
              <Select onValueChange={field.onChange} value={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="Choose a topic" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="booking">Booking</SelectItem>
                  <SelectItem value="billing">Billing</SelectItem>
                  <SelectItem value="other">Something else</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="phone"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Phone (optional)</FormLabel>
              <FormControl>
                <Input
                  type="tel"
                  inputMode="tel"
                  autoComplete="tel"
                  {...field}
                />
              </FormControl>
              <FormDescription>Only if you prefer a call back.</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="message"
          render={({ field }) => (
            <FormItem className="sm:col-span-2">
              <FormLabel>Message</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>
                  I agree to be contacted about this request
                </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 ? "Sending..." : "Send message"}
          </Button>
        </div>
      </form>
    </Form>
  );
}

참고:

  • noValidate는 기본 브라우저 툴팁을 끄므로 zod 메시지만 표시됩니다.
  • 항상 defaultValues를 전달하세요(빈 문자열, undefined 아님). 그래야 첫 렌더링부터 입력이 제어됩니다.
  • Select/RadioGroup에서는 defaultValue 대신 value={field.value}를 사용하세요. 그래야 form.reset()이 값을 초기화합니다.
  • 폼이 TanStack Query 뮤테이션을 통해 제출될 때는 isSubmitting 대신 mutation.isPending을 사용하고, onSubmit에서 mutateAsync를 호출하여 react-hook-form이 대기하도록 하세요.

서버 유효성 검사

동일한 스키마로 파싱하고 필드 오류와 함께 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: "This email is already registered" } }를 반환하세요. RLS 전용 접근이 있는 Supabase 앱에서는 데이터베이스 제약 조건 또는 엣지 함수에서 다시 유효성 검사를 수행하세요. 클라이언트에 의존하지 마십시오.

오류 메시지 스타일

  • 무엇이 잘못되었는지 말고 무엇을 해야 하는지 말하세요: "잘못된 날짜" 대신 "오늘 이후의 날짜를 입력하세요".
  • 사용자의 용어를 사용하고 예상 형식을 인라인으로 표시하세요("DD/MM/YYYY").
  • 한 번에 필드당 하나의 메시지, 레이블을 반복하지 마세요.
  • 메시지는 앱의 언어로 작성하세요. zod 메시지는 일반 문자열이므로 번역된 텍스트를 전달하세요(태국어 현지화 스킬 참조).

상태 체크리스트

  • 비어 있음: 레이블, 유용한 플레이스홀더(명령어가 아닌 예시).
  • 입력 중: 첫 번째 블러까지 오류 없음.
  • 잘못됨: 빨간 테두리 + 필드 아래 메시지(FormMessage), 제출 시 첫 번째 오류로 포커스 이동.
  • 제출 중: 버튼 비활성화 + 스피너 및 진행 레이블, 입력은 읽기 가능 상태 유지.
  • 서버 오류: 제출 버튼 위에 role="alert" 메시지, 입력 유지.
  • 성공: 토스트 또는 인라인 확인, 폼 리셋 또는 리디렉션.
  • 긴 폼에서 저장되지 않은 변경 사항: 이동 전 경고(formState.isDirty 사용).

Individual skills in this repo

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

관련 스킬