폼과 유효성 검사
스택: react-hook-form + zod + @hookform/resolvers/zod + shadcn/ui
Form 컴포넌트(KODRA 스캐폴드에 이미 설치됨). 하나의 zod 스키마가 타입, 클라이언트 유효성 검사 및 서버 유효성 검사의 단일 진실 공급원입니다.
규칙
- 스키마 우선. zod 스키마를 정의하고
z.infer로 TypeScript 타입을 도출합니다. 공유 스키마는src/lib/schemas/<name>.ts에 배치하여 서버 라우트가 동일한 파일을 가져올 수 있도록 합니다. - 서버에서도 유효성 검사. 클라이언트 유효성 검사는 UX를 위한 것일 뿐이며, 서버 라우트, 엣지 함수 또는 데이터베이스 제약 조건이 보안 경계입니다. 동일한 스키마로 본문을 다시 파싱하고 필드 오류를 반환합니다.
- 모든 필드에는 눈에 보이는 레이블이 있어야 하고, 형식이 명확하지 않을 때 유용한 설명이 있어야 하며, 필드 아래에 인라인 오류가 있어야 합니다.
- 유효성 검사 타이밍:
mode: "onTouched"(첫 번째 블러 이후 유효성 검사, 이후 변경 시). 사용자가 처음 입력하는 동안에는 오류를 표시하지 않습니다. - 제출 버튼은 제출이 시작될 때까지 활성화 상태이며, 이후 보류 상태를 표시하고 비활성화되어 이중 제출을 방지합니다.
- 성공 시 실행된 작업(토스트 또는 인라인 메시지)을 표시하고 리셋 또는 이동합니다. 실패 시 사용자의 입력을 유지하고, 폼 근처에 오류를 표시하며, 첫 번째 잘못된 필드로 포커스를 이동합니다(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, "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사용).