[React] React Hook Form, Zod
개요
폼 처리는 매번 state와 onChange를 반복하는 번거로운 작업입니다. React Hook Form은 폼 상태를 효율적으로 관리하고, Zod는 타입 안전한 유효성 검사 스키마를 제공합니다. 두 라이브러리를 함께 사용하면 간결하고 안전한 폼을 빠르게 만들 수 있습니다.
설치
npm install react-hook-form zod @hookform/resolvers
Zod 스키마 기본
import { z } from "zod";
const schema = z.object({
email: z.string().email("올바른 이메일 형식이 아닙니다"),
password: z.string().min(8, "비밀번호는 8자 이상이어야 합니다"),
age: z.number().min(0).max(150).optional(),
role: z.enum(["admin", "user"]).default("user"),
});
// 스키마에서 TypeScript 타입 자동 추출
type FormData = z.infer<typeof schema>;
// → { email: string; password: string; age?: number; role: "admin" | "user" }
로그인 폼 기본 예시
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// 1. 스키마 정의
const loginSchema = z.object({
email: z.string().email("올바른 이메일을 입력하세요"),
password: z.string().min(8, "비밀번호는 8자 이상이어야 합니다"),
});
type LoginFormData = z.infer<typeof loginSchema>;
// 2. 폼 컴포넌트
function LoginForm() {
const {
register, // input을 폼에 등록
handleSubmit, // 제출 처리
formState: { errors, isSubmitting }, // 상태
reset, // 폼 초기화
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema), // Zod 연결
});
const onSubmit = async (data: LoginFormData) => {
console.log("제출 데이터:", data);
// API 호출 등 처리
await new Promise((r) => setTimeout(r, 1000)); // 1초 대기 시뮬레이션
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>이메일</label>
<input type="email" {...register("email")} placeholder="example@email.com" />
{errors.email && <p style=>{errors.email.message}</p>}
</div>
<div>
<label>비밀번호</label>
<input type="password" {...register("password")} />
{errors.password && <p style=>{errors.password.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "로그인 중..." : "로그인"}
</button>
</form>
);
}
회원가입 폼 (복잡한 유효성 검사)
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const registerSchema = z
.object({
name: z.string().min(2, "이름은 2자 이상이어야 합니다").max(20),
email: z.string().email("올바른 이메일 형식이 아닙니다"),
password: z
.string()
.min(8, "8자 이상 입력하세요")
.regex(/[A-Z]/, "대문자를 포함해야 합니다")
.regex(/[0-9]/, "숫자를 포함해야 합니다"),
confirmPassword: z.string(),
agreeToTerms: z.boolean().refine((val) => val === true, {
message: "이용약관에 동의해야 합니다",
}),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"], // 오류를 표시할 필드
message: "비밀번호가 일치하지 않습니다",
});
type RegisterFormData = z.infer<typeof registerSchema>;
function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isValid },
watch,
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
mode: "onChange", // 입력할 때마다 검사 (기본값은 제출 시)
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<div>
<label>이름</label>
<input {...register("name")} />
{errors.name && <p style=>{errors.name.message}</p>}
</div>
<div>
<label>이메일</label>
<input type="email" {...register("email")} />
{errors.email && <p style=>{errors.email.message}</p>}
</div>
<div>
<label>비밀번호</label>
<input type="password" {...register("password")} />
{errors.password && <p style=>{errors.password.message}</p>}
</div>
<div>
<label>비밀번호 확인</label>
<input type="password" {...register("confirmPassword")} />
{errors.confirmPassword && (
<p style=>{errors.confirmPassword.message}</p>
)}
</div>
<div>
<label>
<input type="checkbox" {...register("agreeToTerms")} />
이용약관 동의
</label>
{errors.agreeToTerms && (
<p style=>{errors.agreeToTerms.message}</p>
)}
</div>
<button type="submit" disabled={isSubmitting || !isValid}>
가입하기
</button>
</form>
);
}
자주 쓰는 Zod 유효성 검사
const schema = z.object({
// 문자열
username: z.string()
.min(3).max(20)
.regex(/^[a-zA-Z0-9_]+$/, "영문자, 숫자, 언더스코어만 허용"),
// 숫자
age: z.number().int().positive().min(1).max(120),
// URL
website: z.string().url("올바른 URL 형식이 아닙니다").optional(),
// 한국 전화번호
phone: z.string().regex(/^010-\d{4}-\d{4}$/, "010-0000-0000 형식으로 입력하세요"),
// 선택지
role: z.enum(["admin", "editor", "viewer"]),
// 배열 (최소 1개)
tags: z.array(z.string()).min(1, "태그를 하나 이상 입력하세요"),
// 조건부 필드
isCompany: z.boolean(),
companyName: z.string().optional(),
}).refine((data) => {
if (data.isCompany && !data.companyName) return false;
return true;
}, { path: ["companyName"], message: "회사명을 입력하세요" });