2 분 소요

개요

폼 처리는 매번 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: "회사명을 입력하세요" });


관련 링크