Skip to content

React Hook Form으로 폼 관리하기

React Hook Form이란?

RHF는 React 애플리케이션에서 폼 상태를 효율적으로 관리하기 위한 라이브러리다.
여기서 말하는 폼 상태란 다음과 같은 것들을 의미한다:

  • 입력 값 (email, password 등)
  • 유효성 검증 결과 (에러 메시지, valid 여부)
  • 폼 제출 상태 (isSubmitting)
  • 입력 여부 (isDirty, isTouched)
  • 폼 제어 로직 (reset, watch 등)

💡 즉, 하나의 폼이 가지는 전체 생명주기

렌더링 → 입력 → 검증 → 제출 → 초기화를 일관되게 관리하는 역할을 한다.


왜 React Hook Form을 사용할까?

기존 방식의 문제점

React에서 폼을 만들 때 가장 먼저 떠올리는 건 useState다.
간단한 로그인 폼을 만든다고 해보자.

tsx
function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [emailError, setEmailError] = useState('');
  const [passwordError, setPasswordError] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();

    // 검증 로직
    if (!email) setEmailError('이메일을 입력해주세요');
    if (password.length < 8)
      setPasswordError('비밀번호는 8자 이상이어야 합니다');

    // 실제 로그인 API 호출 등 제출 로직은 생략
  };
  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      {emailError && <p>{emailError}</p>}

      <input
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        type='password'
      />
      {passwordError && <p>{passwordError}</p>}
    </form>
  );
}

문제는 여기서부터다.

  • 입력 필드마다 state가 2개씩 필요 (값 + 에러)
  • 매번 리렌더링 발생 (타이핑할 때마다 전체 컴포넌트 리렌더)
  • 검증 로직이 흩어짐 (onChange, onSubmit, useEffect 등등)
  • 폼이 커질수록 코드 관리 불가능

회원가입 폼처럼 필드가 10개만 넘어가도 state 관리가 복잡해진다.


React Hook Form의 역할 정리

React Hook Form은 전역 상태 관리 라이브러리가 아니다.
많이 쓰이는 상태 관리 도구들과 역할을 비교하면 다음과 같다.

구분역할
Zustand전역 UI 상태 관리
TanStack Query서버 상태 관리
React Hook Form폼 단위 로컬 상태 관리
Zod데이터 스키마 / 유효성 검증

React Hook Form은 특정 폼 안에서만 필요한 상태를 관리하는 데 특화된 도구다.


React Hook Form의 접근 방식

React Hook Form은 비제어 컴포넌트(Uncontrolled Component) 방식을 사용한다.

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

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

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

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

핵심 차이점:

  • state 없이 ref로 input 값 관리 → 타이핑해도 리렌더링 없음
  • 모든 폼 상태가 useForm() 한 곳에 모임
  • 검증은 제출 시점에 일괄 처리 (또는 설정으로 조절 가능)

기본 설치

bash
npm install react-hook-form

React Hook Form은 단독으로 사용할 수도 있지만, 보통 Zod와 함께 폼 검증을 구성한다.
Zod와 함께 사용할 거라면:

bash

npm install react-hook-form zod @hookform/resolvers
  • react-hook-form : 폼 상태 관리
  • zod : 스키마 기반 유효성 검증
  • @hookform/resolvers : Zod와 React Hook Form 연결

React Hook Form 핵심 API

register - 입력 필드 등록

💬 register: 이 input을 React Hook Form이 관리하도록 등록한다.

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

register는 내부적으로 이렇게 동작한다:

tsx
{
  name: 'email',
  ref: (element) => { /* DOM 참조 저장 */ },
  onChange: (e) => { /* 내부 상태 업데이트 */ },
  onBlur: (e) => { /* validation */ }
}

그래서 {...register('email')}을 펼치면 위 속성들이 input에 자동으로 붙는다.

주의할 점:

tsx
// 이렇게 하면 안 됨 (controlled와 uncontrolled 혼용)
<input {...register('email')} value={email} />

// register만 사용하거나
<input {...register('email')} />

// Controller 사용 (제어 컴포넌트가 필요한 경우)
// control은 useForm()에서 함께 제공됨
<Controller name="email" control={control} render={...} />
  • React Hook Form은 비제어 컴포넌트 방식을 기본으로 사용한다.
  • register는 DOM이 값을 관리하도록 위임한다.
  • value를 직접 전달하면 제어 컴포넌트가 되며, 이 둘을 혼용하면 문제가 발생한다.
  • 제어 컴포넌트가 필요한 경우 Controller를 사용해 React Hook Form과 연결한다.

handleSubmit - 제출 전 검증

💬 handleSubmit: 제출 전에 검증부터 해줄게

tsx
const onSubmit = (data) => {
  // 여기는 검증 통과한 경우에만 실행됨
  console.log(data);
};

<form onSubmit={handleSubmit(onSubmit)}>

내부 동작(개념적으로):

  1. 제출 이벤트 발생
  2. 모든 필드 유효성 검증
  3. 에러 발생 시 formState.errors 업데이트 및 제출 중단
  4. 에러가 없으면 onSubmit(data) 실행

💡 handleSubmit?

기존 방식

ts
if (검증 통과) {
  onSubmit()
}

React Hook Form

ts
handleSubmit(onSubmit);
// ↑ 이 안에 위 로직이 들어 있음

handleSubmit은 단순히 submit 이벤트를 처리하는 함수가 아니다.
폼 제출 과정 전체를 감싸는 래퍼 역할을 한다.

  • submit 이벤트를 가로채 기본 동작을 막고
  • 등록된 모든 필드의 값을 수집한 뒤
  • 유효성 검증을 먼저 수행한다
  • 검증에 실패하면 formState.errors만 업데이트하고 제출을 중단한다
  • 모든 검증을 통과한 경우에만 onSubmit(data)를 실행한다

즉, onSubmit은 submit 핸들러가 아니라 검증이 성공했을 때만 호출되는 성공 콜백이다.


mode - 유효성 검증이 실행되는 시점

mode는 유효성 검증이 언제 실행될지를 결정하는 옵션이다.

ts
useForm({
  mode: 'onSubmit', // 기본값
});
  • onSubmit: 제출 시에만 검증 (기본값)
  • onChange: 입력값이 변경될 때마다 검증
  • onBlur: input에서 포커스가 벗어날 때 검증
  • all: 모든 시점에서 검증

폼의 성격과 UX에 따라 적절한 mode를 선택하는 것이 중요하다.

일반적으로 로그인/회원가입 폼은 onSubmit,
즉각적인 피드백이 중요한 경우에는 onChange가 자주 사용된다.


formState - 폼의 현재 상태

💬 formState: 지금 폼이 어떤 상태야?

ts
const {
  formState: { errors, isSubmitting, isDirty, isValid },
} = useForm();
  • errors: 각 필드의 에러 메시지
  • isSubmitting: 제출중인지
  • isDirty: 하나라도 수정했는지
  • isValid: 모든 검증 통과했는지

formState는 폼의 현재 상태를 나타내는 읽기 전용 정보로, UI 제어(disabled, loading 등)에 주로 활용된다.


watch - 실시간 값 감지

💬 watch: 특정 필드 값 실시간으로 볼래유

tsx
const password = watch('password');

// 비밀번호 확인 검증에 유용
const passwordConfirm = watch('passwordConfirm');

useEffect(() => {
  if (password !== passwordConfirm) {
    setError('passwordConfirm', {
      message: '비밀번호가 일치하지 않습니다',
    });
  } else {
    clearErrors('passwordConfirm');
  }
}, [password, passwordConfirm]);
  • watch 대신 getValues로 한 번만 읽는 경우도 많다.
  • 주의: watch()는 값이 변경될 때마다 컴포넌트를 리렌더링한다.
    필요한 필드만 선택적으로 사용하고, 전체 폼을 watch하는 것은 피하는 것이 좋다.

setValue - 값 직접 설정

💬 setValue: 외부에서 값 넣어주기

tsx
// API로 받아온 데이터로 폼 채우기
useEffect(() => {
  if (userData) {
    setValue('email', userData.email);
    setValue('nickname', userData.nickname);
  }
}, [userData]);

reset - 폼 초기화

tsx
const onSubmit = async (data) => {
  await api.post('/login', data);
  reset(); // 제출 후 폼 비우기
};

성능 관점에서의 팁

성능 최적화

  • watch는 필요한 경우에만 사용
  • 실시간 검증이 필요 없다면 mode: 'onSubmit'을 유지하는 것이 비용이 적다.

자주 하는 실수

  1. registervalue를 함께 사용
  2. controlled / uncontrolled 혼용
  3. watch 과다 사용으로 성능 저하

Zod와 함께 사용하는 이유

React Hook Form 단독으로도 검증이 가능하다:

tsx
<input
  {...register('email', {
    required: '이메일은 필수입니다',
    pattern: {
      value: /^\S+@\S+$/i,
      message: '이메일 형식이 아닙니다',
    },
  })}
/>

하지만 이 방식의 문제:

  • 검증 로직이 UI 코드에 섞임
  • 재사용 불가능
  • 타입 안정성 부족

React Hook Form은 입력값을 관리하는 데 집중하고, 유효성 검증 로직은 외부 라이브러리에 위임할 수 있다. 이때 많이 사용되는 조합이 바로 Zod + React Hook Form이다.

  • Zod → 입력값의 구조와 규칙을 스키마로 정의
  • React Hook Form → 입력값 관리 + 제출 흐름 제어
  • @hookform/resolvers → Zod 스키마를 React Hook Form과 연결
tsx
// 검증 스키마를 별도로 분리
const loginSchema = z.object({
  email: z.string().email('올바른 이메일 형식이 아닙니다'),
  password: z.string().min(8, '비밀번호는 8자 이상이어야 합니다'),
});

const form = useForm({
  resolver: zodResolver(loginSchema),
});

이 구조의 장점은:

  • 타입과 검증 규칙을 한 곳에서 관리 가능
  • 검증 로직과 UI 로직 분리
  • 서버/클라이언트 간 스키마 재사용 가능

언제 React Hook Form을 사용하는 것이 적합할까?

  • 입력 필드가 3개 이상인 폼
  • 복잡한 검증이 필요한 경우
  • 로그인, 회원가입, 설정 페이지 등

굳이 안 써도 되는 경우:

  • 검색창처럼 단일 input
  • 실시간 피드백이 핵심인 UI (auto-complete 등)


정리

  • React Hook Form은 폼 단위 로컬 상태 관리 라이브러리다.
  • 비제어 컴포넌트 기반으로 리렌더링을 최소화한다.
  • register, handleSubmit, formState가 핵심 API다.
  • Zod와 결합하면 타입 안정성과 검증 로직 분리가 가능하다.
  • 복잡한 폼일수록 React Hook Form의 장점이 명확해진다.