← 글 목록

notes

React HookForm

React Hook Form의 폼 상태·검증·등록 방식과 성능 최적화 메모.

  • 기본이 비제어 방식이라 입력할 때마다 폼 전체가 리렌더되지 않는다.
  • 일반 input 은 register, UI 라이브러리 컴포넌트는 Controller 로 붙인다.
  • 검증은 zod 스키마를 resolver 로 넘기면 타입과 규칙을 한 곳에서 관리할 수 있다.

왜 쓰나

폼을 useState 로 직접 관리하면 글자 하나 칠 때마다 컴포넌트가 다시 그려진다. 필드가 많아지면 느려지고, 검증, 에러 메시지, 제출 중 상태까지 직접 만들어야 한다.

React Hook Form 은 input 을 ref 로 물고 값은 DOM 에 둔다. 필요할 때만 읽어서 리렌더가 적다. 검증, 에러, 제출 상태도 formState 로 같이 준다.


기본 사용법

npm install react-hook-form

useForm 으로 폼을 만들고, input 에 register 를 펼쳐 넣고, handleSubmit 으로 제출을 감싼다.

기본 예제
import { useForm } from 'react-hook-form'

type FormValues = { email: string; password: string }

export default function LoginForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<FormValues>({ defaultValues: { email: '', password: '' } })

  const onSubmit = async (data: FormValues) => {
    await login(data)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email', { required: '이메일을 입력해 주세요' })} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" {...register('password', { minLength: { value: 8, message: '8자 이상' } })} />
      {errors.password && <p>{errors.password.message}</p>}

      <button disabled={isSubmitting}>로그인</button>
    </form>
  )
}

defaultValues 는 처음부터 넣어 두는 게 좋다. reset 이나 dirty 판정이 이 값을 기준으로 돈다.


zod 로 검증하기

규칙이 많아지면 register 옵션에 흩어 두기보다 스키마 하나로 모으는 게 낫다. @hookform/resolvers 의 zodResolver 를 쓴다.

zodResolver 예제
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const schema = z.object({
  email: z.string().email('이메일 형식이 아니에요'),
  password: z.string().min(8, '8자 이상'),
})

type FormValues = z.infer<typeof schema>

const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
  resolver: zodResolver(schema),
  defaultValues: { email: '', password: '' },
})

타입을 z.infer 로 뽑으면 스키마와 폼 타입이 항상 같이 움직인다. 요즘 @hookform/resolvers 는 zod 3.25 이상과 zod 4 를 둘 다 받는다.


Controller 와 useFieldArray

MUI Select, DatePicker 처럼 ref 를 제대로 안 넘겨주는 컴포넌트는 register 가 안 먹힌다. 이때 Controller 로 감싼다.

항목을 추가, 삭제하는 동적 목록은 useFieldArray 를 쓴다.

Controller, useFieldArray 예제
import { Controller, useFieldArray, useForm } from 'react-hook-form'

const { control, register } = useForm({
  defaultValues: { category: '', items: [{ name: '' }] },
})

// 제어 컴포넌트
<Controller
  name="category"
  control={control}
  render={({ field }) => <Select {...field} options={options} />}
/>

// 동적 목록
const { fields, append, remove } = useFieldArray({ control, name: 'items' })

{fields.map((f, i) => (
  <div key={f.id}>
    <input {...register(`items.${i}.name`)} />
    <button type="button" onClick={() => remove(i)}>삭제</button>
  </div>
))}
<button type="button" onClick={() => append({ name: '' })}>추가</button>

useFieldArray 에서 key 는 꼭 훅이 준 f.id 를 쓴다. 인덱스를 key 로 쓰면 삭제할 때 값이 엉뚱한 줄로 밀린다.


값 구독과 성능

특정 필드 값에 따라 화면을 바꿔야 할 때가 있다. watch 는 폼을 만든 컴포넌트 전체를 리렌더하고, useWatch 는 그걸 쓴 컴포넌트만 리렌더한다.

그래서 큰 폼에서는 값을 보여주는 부분만 작은 컴포넌트로 떼고 거기서 useWatch 를 쓴다. 렌더 없이 값 변화만 받고 싶으면 subscribe 를 쓴다.


v8 베타에서 바뀌는 것

2026년 10월 기준 안정판은 7.x 다. v8 은 react-hook-form@beta 로 설치하는 베타고, React Compiler 대응과 깨지는 변경이 같이 들어 있다.

  • useFieldArray 는 id 대신 key 를 내준다. keyName 옵션은 없어진다.
  • Controller 의 field.ref 가 실제 DOM 요소를 가리킨다.
  • watch 에 콜백을 넘기던 형태는 빠지고 subscribe 를 쓴다.

지금 프로젝트는 7.x 에 두고, v8 이 정식으로 나오면 위 세 군데부터 고치면 된다.