30개 필드가 있는 폼에서 하나의 input에 글자를 입력할 때마다 전체가 리렌더링되면 어떨까요?

React Hook Form(RHF)은 비제어 컴포넌트 기반으로 동작하여 리렌더링을 최소화하는 폼 라이브러리입니다. Formik이 제어 컴포넌트 기반으로 매 입력마다 state를 업데이트하는 것과 근본적으로 다른 접근입니다.

설치와 기본 사용

BASH
npm install react-hook-form
JSX
import { useForm } from 'react-hook-form';

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

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

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

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

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

register의 내부 동작

register('email')을 호출하면 다음 객체가 반환됩니다.

JS
{
  name: 'email',
  ref: (element) => { /* DOM 참조 저장 */ },
  onChange: (event) => { /* 내부 저장소에 값 업데이트 */ },
  onBlur: (event) => { /* 터치 상태 기록 */ },
}

이것을 {...register('email')}로 스프레드하면 input에 자동으로 바인딩됩니다.

핵심: setState를 호출하지 않는다

PLAINTEXT
[일반 제어 컴포넌트]
입력 → onChange → setState → 리렌더링 → DOM 업데이트

[React Hook Form]
입력 → onChange → 내부 저장소에 기록 (React state 아님)
제출 → 내부 저장소에서 값 읽기 → onSubmit 호출

RHF는 입력할 때 React state를 건드리지 않습니다. 값은 ref를 통해 DOM에서 직접 관리되고, 내부 저장소(plain JavaScript object)에만 기록됩니다. 그래서 리렌더링이 발생하지 않습니다.

리렌더링이 발생하는 순간

RHF에서도 리렌더링이 발생하는 경우가 있습니다.

JSX
const {
  formState: { errors, isSubmitting, isDirty, isValid },
} = useForm({ mode: 'onChange' });
  • **에러 상태 변경 **: 검증 에러가 추가되거나 사라질 때
  • ** 제출 상태 변경 **: isSubmitting이 변경될 때
  • **watch 호출 **: 특정 필드를 구독할 때
  • mode: 'onChange': 매 입력마다 검증할 때 (기본은 onSubmit)

watch vs getValues

JSX
function WatchExample() {
  const { register, watch, getValues } = useForm();

  // watch — 값 변경 시 리렌더링 유발
  const email = watch('email');

  const handleClick = () => {
    // getValues — 호출 시점의 값만 읽음, 리렌더링 없음
    const values = getValues();
    console.log(values);
  };

  return (
    <form>
      <input {...register('email')} />
      {/* email이 바뀔 때마다 이 컴포넌트가 리렌더링됨 */}
      <p>미리보기: {email}</p>
      <button type="button" onClick={handleClick}>현재 값 확인</button>
    </form>
  );
}

실시간 미리보기가 필요하면 watch, 특정 시점에만 값을 읽으면 getValues를 사용합니다.

검증 모드

JSX
const { register, handleSubmit, formState: { errors } } = useForm({
  mode: 'onSubmit',      // 기본값: 제출 시에만 검증
  // mode: 'onBlur',     // 포커스 아웃 시 검증
  // mode: 'onChange',   // 매 입력마다 검증 (리렌더링 많음)
  // mode: 'onTouched',  // 첫 blur 후부터 onChange 검증
  // mode: 'all',        // onBlur + onChange
});
  • onSubmit이 성능상 가장 좋습니다
  • onTouched가 UX와 성능의 균형이 좋습니다 (첫 터치 전에는 에러를 보여주지 않음)
  • onChange는 매 입력마다 검증하므로 리렌더링이 많아집니다

Zod/Yup 검증 통합

RHF는 resolver를 통해 외부 검증 라이브러리와 연동합니다.

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

// Zod 스키마 정의
const signupSchema = z.object({
  email: z.string().email('올바른 이메일을 입력해주세요'),
  password: z
    .string()
    .min(8, '8자 이상')
    .regex(/[A-Z]/, '대문자 포함')
    .regex(/[0-9]/, '숫자 포함'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: '비밀번호가 일치하지 않습니다',
  path: ['confirmPassword'],
});

// 스키마에서 타입 추론
type SignupForm = z.infer<typeof signupSchema>;

function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<SignupForm>({
    resolver: zodResolver(signupSchema),
  });

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

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

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

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

Zod와 함께 사용하면 검증 로직과 TypeScript 타입을 한 곳에서 관리할 수 있습니다.

useFieldArray — 동적 필드

JSX
import { useForm, useFieldArray } from 'react-hook-form';

function SkillsForm() {
  const { register, control, handleSubmit } = useForm({
    defaultValues: {
      skills: [{ name: '', level: 'beginner' }],
    },
  });

  const { fields, append, remove, move } = useFieldArray({
    control,
    name: 'skills',
  });

  return (
    <form onSubmit={handleSubmit(console.log)}>
      {fields.map((field, index) => (
        <div key={field.id}>
          <input {...register(`skills.${index}.name`)} placeholder="스킬" />
          <select {...register(`skills.${index}.level`)}>
            <option value="beginner">초급</option>
            <option value="intermediate">중급</option>
            <option value="advanced">고급</option>
          </select>
          <button type="button" onClick={() => remove(index)}>삭제</button>
        </div>
      ))}

      <button type="button" onClick={() => append({ name: '', level: 'beginner' })}>
        스킬 추가
      </button>
      <button type="submit">저장</button>
    </form>
  );
}

주의할 점

  • keyindex가 아닌 field.id를 사용해야 합니다. RHF가 내부적으로 생성하는 고유 ID입니다
  • remove, append 등은 최적화되어 최소한의 리렌더링만 유발합니다

Controller — 제어 컴포넌트 통합

외부 UI 라이브러리(MUI, Ant Design 등)의 제어 컴포넌트를 사용할 때는 Controller를 씁니다.

JSX
import { Controller, useForm } from 'react-hook-form';
import DatePicker from 'react-datepicker';

function EventForm() {
  const { control, handleSubmit } = useForm();

  return (
    <form onSubmit={handleSubmit(console.log)}>
      <Controller
        name="eventDate"
        control={control}
        rules={{ required: '날짜를 선택해주세요' }}
        render={({ field, fieldState: { error } }) => (
          <>
            <DatePicker
              selected={field.value}
              onChange={field.onChange}
              onBlur={field.onBlur}
            />
            {error && <span>{error.message}</span>}
          </>
        )}
      />
      <button type="submit">등록</button>
    </form>
  );
}

Controller는 내부적으로 watch를 사용하므로 값 변경 시 리렌더링이 발생합니다. 가능하면 register를 우선 사용하세요.

성능 비교

PLAINTEXT
30개 필드 폼에서 1개 필드 입력 시:

[Formik / 일반 제어]
→ 전체 폼 컴포넌트 리렌더링 (30개 input 모두)

[React Hook Form]
→ 리렌더링 0회 (에러 변경 시에만 해당 필드)

RHF의 공식 벤치마크에 따르면 마운트 속도, 입력 속도 모두 Formik보다 빠릅니다. 필드 수가 많아질수록 차이가 두드러집니다.

DevTools

BASH
npm install -D @hookform/devtools
JSX
import { DevTool } from '@hookform/devtools';

function MyForm() {
  const { control } = useForm();

  return (
    <>
      <form>{/* ... */}</form>
      <DevTool control={control} /> {/* 개발 환경에서만 */}
    </>
  );
}

DevTool을 통해 각 필드의 값, 에러, 터치 상태를 실시간으로 확인할 수 있습니다.

정리

React Hook Form이 빠른 이유는 "비제어 컴포넌트 + ref 기반 값 추적"이라는 근본적인 설계 덕분입니다.

  • register는 ref, onChange, onBlur를 반환하여 DOM과 직접 소통합니다
  • 입력 시 React state를 업데이트하지 않으므로 리렌더링이 최소화됩니다
  • watch는 리렌더링을 유발하고, getValues는 유발하지 않습니다
  • Zod/Yup과의 resolver 연동으로 타입 안전한 검증이 가능합니다
  • useFieldArray로 동적 필드를 효율적으로 관리합니다
  • 외부 제어 컴포넌트는 Controller로 통합합니다

주의할 점

watch를 남용하면 React Hook Form의 성능 이점이 사라짐

watch는 해당 필드가 변경될 때마다 리렌더링을 유발 합니다. 값을 읽기만 하면 되는 경우에는 getValues를, 이벤트 기반 처리에는 onChange 콜백을 사용해야 합니다.

외부 UI 라이브러리 컴포넌트에 register를 직접 사용할 수 없음

MUI, Ant Design 등의 컴포넌트는 내부적으로 ref를 다르게 처리하므로 register가 동작하지 않습니다. Controller 컴포넌트로 감싸서 통합해야 합니다.

폼 필드가 5개 이하면 useState로도 충분하지만, 복잡한 폼이라면 React Hook Form이 성능과 개발 경험 모두에서 큰 차이를 만들어 줍니다.

댓글 로딩 중...