개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← 글 목록

React Hook Form — 폼 상태와 유효성 검증 라이브러리

2026년 6월 1일
React폼ValidationReact Hook Form

React Hook Form은 비제어 컴포넌트(uncontrolled component) 방식으로 폼을 관리하는 라이브러리입니다. 입력값을 React 상태에 저장하지 않고 DOM ref로 직접 읽기 때문에, 입력할 때마다 발생하는 불필요한 리렌더링을 근본적으로 없앱니다.

npm install react-hook-form

기본 사용 구조는 다음과 같습니다.

import { useForm } from "react-hook-form";

interface FormValues {
  email: string;
  password: string;
}

function LoginForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormValues>();

  const onSubmit = (data: FormValues) => {
    console.log(data); // { email: "...", password: "..." }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email", { required: "이메일을 입력해 주세요." })} />
      {errors.email && <p>{errors.email.message}</p>}
      <button type="submit">로그인</button>
    </form>
  );
}

1. register

register("fieldName", validationRules)를 호출하면 해당 필드의 ref와 이벤트 핸들러가 담긴 객체가 반환됩니다. 스프레드로 입력 요소에 전달합니다.

const { register } = useForm<{ username: string; age: number }>();

// 기본 사용
<input {...register("username")} />

// 유효성 규칙과 함께
<input
  type="number"
  {...register("age", {
    required: "나이를 입력해 주세요.",
    min: { value: 1, message: "1 이상이어야 합니다." },
    max: { value: 120, message: "120 이하이어야 합니다." },
    valueAsNumber: true, // 문자열이 아닌 숫자로 변환
  })}
/>

register가 반환하는 객체의 구조는 다음과 같습니다.

{
  name: "username",
  ref: (el) => { /* DOM ref 등록 */ },
  onChange: (e) => { /* 변경 추적 */ },
  onBlur: (e) => { /* 포커스 아웃 시 검증 */ },
}

React 상태를 거치지 않고 DOM에서 직접 값을 읽기 때문에 타이핑 시 리렌더링이 발생하지 않습니다.


2. handleSubmit

handleSubmit(onValid, onInvalid)는 두 가지 콜백을 받습니다. onValid는 모든 검증 통과 시, onInvalid는 검증 실패 시 호출됩니다.

const { handleSubmit } = useForm<FormValues>();

const onValid = async (data: FormValues) => {
  await submitToServer(data);
};

const onInvalid = (errors: FieldErrors<FormValues>) => {
  console.log("검증 실패:", errors);
};

<form onSubmit={handleSubmit(onValid, onInvalid)}>
  ...
</form>

handleSubmit은 event.preventDefault를 자동으로 호출하므로 직접 호출할 필요가 없습니다. 또한 isSubmitting이 true인 동안에는 중복 제출을 자동으로 차단합니다.

// 비동기 제출 중 버튼 비활성화
const { formState: { isSubmitting } } = useForm<FormValues>();

<button type="submit" disabled={isSubmitting}>
  {isSubmitting ? "전송 중..." : "제출"}
</button>

3. Controller

register는 DOM ref 기반으로 동작하기 때문에 커스텀 컴포넌트나 외부 UI 라이브러리에는 사용할 수 없습니다. Controller는 이를 해결하는 브릿지입니다.

import { useForm, Controller } from "react-hook-form";
import Select from "react-select";
import DatePicker from "react-datepicker";

interface FormValues {
  category: { value: string; label: string } | null;
  startDate: Date | null;
}

function FilterForm() {
  const { control, handleSubmit } = useForm<FormValues>({
    defaultValues: { category: null, startDate: null },
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <Controller
        name="category"
        control={control}
        rules={{ required: "카테고리를 선택해 주세요." }}
        render={({ field, fieldState }) => (
          <>
            <Select
              {...field}
              options={[
                { value: "frontend", label: "프론트엔드" },
                { value: "backend", label: "백엔드" },
              ]}
            />
            {fieldState.error && <p>{fieldState.error.message}</p>}
          </>
        )}
      />

      <Controller
        name="startDate"
        control={control}
        render={({ field }) => (
          <DatePicker
            selected={field.value}
            onChange={field.onChange}
          />
        )}
      />

      <button type="submit">검색</button>
    </form>
  );
}

render prop으로 전달받는 field 객체에는 value, onChange, onBlur, ref가 포함되어 외부 컴포넌트에 스프레드하거나 개별 전달합니다.


4. watch

watch는 해당 필드 값이 바뀔 때마다 컴포넌트를 리렌더링합니다. 다른 필드 값에 따라 UI를 조건부 렌더링하거나 연동 로직을 구현할 때 사용합니다.

const { register, watch } = useForm<{
  hasAddress: boolean;
  address: string;
}>();

// 단일 필드 구독
const hasAddress = watch("hasAddress");

// 여러 필드 동시 구독
const [firstName, lastName] = watch(["firstName", "lastName"]);

// 전체 폼 값 구독
const allValues = watch();

return (
  <form>
    <label>
      <input type="checkbox" {...register("hasAddress")} />
      배송지 입력
    </label>

    {/* hasAddress가 true일 때만 주소 입력 필드 표시 */}
    {hasAddress && (
      <input
        {...register("address", { required: "주소를 입력해 주세요." })}
        placeholder="배송 주소"
      />
    )}
  </form>
);

렌더링 없이 값만 읽어야 한다면 getValues를 사용합니다. watch는 구독이지만 getValues는 단순 읽기입니다.


5. reset

폼 제출 성공 후 초기화하거나, 외부에서 받아온 데이터로 폼을 채울 때 사용합니다.

const { register, handleSubmit, reset } = useForm<FormValues>({
  defaultValues: { name: "", email: "" },
});

// 폼 제출 성공 후 초기화
const onSubmit = async (data: FormValues) => {
  await submitToServer(data);
  reset(); // defaultValues로 초기화
};

// 서버 데이터로 폼 채우기 (수정 폼)
useEffect(() => {
  if (userData) {
    reset({
      name: userData.name,
      email: userData.email,
    });
  }
}, [userData, reset]);

// 일부 필드만 초기화하고 나머지는 유지
reset(
  { name: "" },
  {
    keepErrors: true,    // 에러 유지
    keepDirty: true,     // dirty 상태 유지
    keepValues: true,    // 나머지 필드 값 유지
  }
);

reset의 두 번째 인자로 어떤 상태를 유지할지 세밀하게 제어할 수 있습니다.


6. setValue

사용자 입력이 아닌 외부 이벤트(버튼 클릭, API 응답 등)로 필드 값을 변경해야 할 때 사용합니다.

const { register, setValue, watch } = useForm<{
  country: string;
  city: string;
}>();

const country = watch("country");

// 국가 선택 시 도시를 자동으로 첫 번째 값으로 설정
useEffect(() => {
  if (country === "KR") {
    setValue("city", "서울", {
      shouldValidate: true,  // 값 변경 후 즉시 유효성 검사 실행
      shouldDirty: true,     // isDirty를 true로 표시
      shouldTouch: true,     // isTouched를 true로 표시
    });
  }
}, [country, setValue]);

shouldValidate: true를 주면 setValue 호출 직후 해당 필드의 유효성 검사가 실행됩니다.


7. formState

formState는 폼의 현재 상태를 나타내는 다양한 플래그를 포함합니다.

const {
  formState: {
    errors,       // 필드별 유효성 에러 객체
    isDirty,      // 초기값에서 하나라도 변경되었으면 true
    isValid,      // 모든 필드가 유효하면 true
    isSubmitting, // handleSubmit의 콜백이 실행 중이면 true
    isSubmitted,  // 제출 시도가 한 번 이상 있었으면 true
    isSubmitSuccessful, // 마지막 제출이 성공했으면 true
    dirtyFields,  // 변경된 필드 이름들의 객체
    touchedFields, // 포커스 후 블러된 필드 이름들의 객체
  }
} = useForm<FormValues>();

실전 활용 예시입니다.

<button
  type="submit"
  disabled={!isDirty || isSubmitting}
>
  {isSubmitting ? "저장 중..." : "저장"}
</button>

{isSubmitSuccessful && <p>저장되었습니다.</p>}

{errors.email && <p role="alert">{errors.email.message}</p>}

formState의 각 값은 성능을 위해 필요한 것만 구조 분해하면 관련 상태가 변경될 때만 리렌더링됩니다.


8. Validation

Validation: register의 두 번째 인자로 전달하는 내장 유효성 규칙 옵션 집합

별도 라이브러리 없이 내장 규칙만으로 대부분의 검증을 처리할 수 있습니다.

<input
  {...register("email", {
    required: "이메일을 입력해 주세요.",
    pattern: {
      value: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/,
      message: "올바른 이메일 형식이 아닙니다.",
    },
  })}
/>

<input
  type="password"
  {...register("password", {
    required: "비밀번호를 입력해 주세요.",
    minLength: { value: 8, message: "8자 이상 입력해 주세요." },
    maxLength: { value: 20, message: "20자 이하로 입력해 주세요." },
  })}
/>

<input
  type="number"
  {...register("age", {
    min: { value: 14, message: "만 14세 이상만 가입 가능합니다." },
    max: { value: 100, message: "올바른 나이를 입력해 주세요." },
    valueAsNumber: true,
  })}
/>

// validate: 커스텀 검증 함수
<input
  {...register("username", {
    validate: {
      noSpace: (v) => !v.includes(" ") || "공백을 포함할 수 없습니다.",
      asyncCheck: async (v) => {
        const taken = await checkUsernameTaken(v);
        return !taken || "이미 사용 중인 닉네임입니다.";
      },
    },
  })}
/>

validate에 객체를 전달하면 여러 커스텀 검증을 병렬로 실행합니다. 각 키는 에러 타입 이름이 됩니다.


9. Resolver

Resolver: Zod, Yup 등 외부 스키마 검증 라이브러리의 결과를 React Hook Form의 에러 형식으로 변환하는 어댑터

검증 로직이 복잡하거나, 타입 추론과 런타임 검증을 동시에 원한다면 Zod 같은 스키마 라이브러리와 연동합니다.

npm install zod @hookform/resolvers
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

// 스키마 정의 — 유효성 규칙과 타입이 동시에 선언됨
const schema = z
  .object({
    email: z
      .string()
      .min(1, "이메일을 입력해 주세요.")
      .email("올바른 이메일 형식이 아닙니다."),
    password: z
      .string()
      .min(8, "8자 이상 입력해 주세요.")
      .max(20, "20자 이하로 입력해 주세요."),
    confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword, {
    path: ["confirmPassword"],
    message: "비밀번호가 일치하지 않습니다.",
  });

// 스키마에서 타입을 추론 — 별도 interface 선언 불필요
type FormValues = z.infer<typeof schema>;

function SignUpForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>({
    resolver: zodResolver(schema),
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <input {...register("email")} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" {...register("password")} />
      {errors.password && <p>{errors.password.message}</p>}

      <input type="password" {...register("confirmPassword")} />
      {errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}

      <button type="submit">가입</button>
    </form>
  );
}

zodResolver는 제출 시 스키마 전체를 검증하고, 에러를 React Hook Form의 errors 객체에 자동으로 매핑합니다. @hookform/resolvers는 Yup, Joi, Valibot 등 다른 스키마 라이브러리용 리졸버도 제공합니다.

← 이전 글React Router — SPA URL 기반 라우팅 라이브러리
다음 글 →React Testing Library — 사용자 관점에서 컴포넌트를 테스트