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 를 쓴다.
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 를 쓴다.
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 이 정식으로 나오면 위 세 군데부터 고치면 된다.