React Hook Form으로 폼 만들기: register·handleSubmit, Zod 검증, Controller, 배열 필드
이 글의 핵심
React Hook Form이 비제어 입력으로 리렌더링을 줄이는 방식, 검증 규칙과 Zod 스키마 연결, UI 라이브러리 컴포넌트를 Controller로 감싸는 법, 동적 배열 필드를 예제로 다룹니다.
이 글의 핵심
React Hook Form으로 폼을 구현하는 방법을 정리한 글입니다. register, handleSubmit, Validation, Zod 통합, 성능 관련 주의점을 예제로 다룹니다.
실무에서 마주치는 문제들
리렌더링이 많아요
useState로 입력값을 관리하는 제어(controlled) 컴포넌트는 글자 하나를 칠 때마다 상태가 바뀌고, 그 상태를 가진 폼 컴포넌트 전체가 다시 렌더링됩니다. 입력 필드가 30개인 폼이라면 한 글자에 30개 필드가 모두 다시 그려집니다. React Hook Form은 입력값을 DOM 요소 자체에 두고(비제어, uncontrolled) 필요할 때 ref로 읽어 오기 때문에, 타이핑 중에는 폼 컴포넌트가 다시 렌더링되지 않습니다. 에러 메시지처럼 화면에 보여야 하는 상태가 바뀔 때만 렌더링됩니다.
Validation이 복잡해요
직접 검증을 구현하면 언제 검증할지(입력 중, 포커스를 벗어날 때, 제출할 때), 에러를 언제 지울지, 첫 번째 에러 필드로 포커스를 옮길지 같은 규칙을 모두 코드로 짜야 합니다. React Hook Form은 필드마다 규칙을 선언하면 이 타이밍을 mode 옵션 하나로 제어합니다.
타입 안전성이 부족해요
폼 데이터 타입과 검증 규칙을 따로 관리하면 둘이 어긋나기 쉽습니다. Zod 스키마를 쓰면 검증 규칙에서 TypeScript 타입을 뽑아낼 수 있어, 규칙을 바꾸면 타입도 함께 바뀝니다.
비제어 방식의 대가도 있습니다. 입력값이 React 상태에 없으므로 “입력하는 대로 미리보기를 갱신”처럼 값을 실시간으로 화면에 반영하려면 watch나 useWatch로 따로 구독해야 하고, 이를 무심코 쓰면 줄였던 리렌더링이 다시 늘어납니다. 또 표준 <input>이 아닌 UI 라이브러리 컴포넌트는 Controller로 감싸야 해서 코드가 조금 길어집니다.
비제어 입력 기반이라 빠른 이유
핵심 특징
React Hook Form은 비제어 입력을 기반으로 한 React 폼 라이브러리입니다. 주요 장점:
- 적은 리렌더링: 타이핑 중 폼 전체가 다시 그려지지 않음
- 간단한 API:
register한 번으로 입력 연결 - Validation: HTML 표준 규칙과 비슷한 내장 검증
- TypeScript: 필드 이름과 값 타입 추론
- 의존성 없음: 외부 런타임 의존성이 없는 작은 패키지
설치와 기본 폼
설치
npm install react-hook-form
기본 폼
import { useForm } from 'react-hook-form';
interface FormData {
email: string;
password: string;
}
export default function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormData>();
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: 'Email is required' })} />
{errors.email && <span>{errors.email.message}</span>}
<input
type="password"
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
})}
/>
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Login</button>
</form>
);
}
register('email', 규칙)은 name, ref, onChange, onBlur 네 가지 속성을 담은 객체를 반환하고, 스프레드 문법으로 <input>에 그대로 넘깁니다. React Hook Form은 이 ref로 DOM 요소를 기억해 두었다가 제출할 때 값을 읽습니다. 그래서 register 결과를 스프레드한 뒤 같은 요소에 ref나 onChange를 따로 지정하면 React Hook Form의 핸들러를 덮어써 값이 등록되지 않습니다. 둘 다 필요하다면 const { ref, onChange, ...rest } = register('email')로 꺼내서 직접 합쳐야 합니다.
handleSubmit(onSubmit)은 제출 이벤트를 받아 preventDefault()를 호출하고, 검증을 실행해 모두 통과했을 때만 onSubmit에 값을 넘깁니다. 실패하면 errors를 갱신하고 첫 번째 에러 필드로 포커스를 옮깁니다. 두 번째 인자로 실패 콜백을 넘길 수도 있습니다. 기본 검증 시점(mode)은 onSubmit이라 처음에는 제출할 때만 검증하고, 한 번 제출한 뒤에는 reValidateMode 기본값인 onChange에 따라 입력할 때마다 다시 검증해 에러를 지웁니다. 사용자가 입력을 시작하자마자 빨간 에러가 뜨는 것을 피하려는 기본값이며, 필드를 벗어날 때 검증하고 싶다면 useForm({ mode: 'onBlur' })로 바꿉니다.
내장 규칙과 커스텀 Validation
내장 Validation
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
})}
/>
<input
{...register('age', {
required: true,
min: { value: 18, message: 'Must be 18+' },
max: { value: 120, message: 'Invalid age' },
})}
type="number"
/>
required: true처럼 메시지 없이 규칙만 주면 errors.age.message는 빈 문자열이라, 화면에 에러를 출력하는 코드가 있어도 아무것도 보이지 않습니다. 에러가 났는데 메시지가 안 보이는 경우는 대부분 이 때문이므로, 모든 규칙에 메시지를 붙이는 습관이 좋습니다. 대신 errors.age.type으로 어떤 규칙이 실패했는지('required', 'min') 알 수 있습니다.
type="number" 입력의 값은 DOM에서 읽으면 문자열입니다. min/max 검증은 내부에서 숫자로 변환해 비교하므로 동작하지만, 제출된 데이터의 age는 "25"라는 문자열로 들어옵니다. 숫자로 받으려면 register('age', { valueAsNumber: true })를 지정해야 하며, 이 옵션이 없으면 TypeScript 타입은 number인데 실제 값은 문자열인 불일치가 생겨 서버 쪽 검증에서 뒤늦게 발견되곤 합니다.
커스텀 Validation
<input
{...register('username', {
required: true,
validate: async (value) => {
const exists = await checkUsernameExists(value);
return !exists || 'Username already taken';
},
})}
/>
validate 함수는 true를 반환하면 통과, 문자열을 반환하면 그 문자열이 에러 메시지가 됩니다. false를 반환하면 메시지 없는 실패입니다. 비동기 함수도 되므로 서버에 아이디 중복을 물어볼 수 있습니다.
비동기 검증에는 알아 둘 부작용이 있습니다. 앞에서 설명한 재검증 규칙 때문에 한 번 제출한 뒤에는 글자를 칠 때마다 이 함수가 실행되어 API 요청이 연달아 나갑니다. 응답 순서가 뒤바뀌면 이전 입력에 대한 결과가 최신 에러로 표시되는 문제도 생깁니다. 중복 확인처럼 비싼 검증은 mode: 'onBlur'로 시점을 늦추거나, 디바운스를 적용하거나, 최종 확인은 서버가 제출 시점에 하고 그 결과를 setError('username', { message })로 표시하는 편이 안정적입니다.
Zod 스키마로 검증하기
설치
npm install @hookform/resolvers zod
사용
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
age: z.number().min(18, 'Must be 18+'),
});
type FormData = z.infer<typeof schema>;
export default function SignupForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <span>{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span>{errors.password.message}</span>}
<input type="number" {...register('age', { valueAsNumber: true })} />
{errors.age && <span>{errors.age.message}</span>}
<button type="submit">Sign Up</button>
</form>
);
}
resolver를 지정하면 register의 내장 규칙 대신 스키마가 검증을 맡습니다. 둘을 함께 쓰면 내장 규칙은 무시되므로 한쪽으로 통일해야 합니다. z.infer<typeof schema>로 만든 타입을 useForm에 넘기면 register('emali')처럼 오타가 난 필드 이름이 컴파일 에러가 되고, 같은 스키마를 서버 API의 요청 검증에도 재사용할 수 있다는 것이 Zod를 붙이는 가장 큰 이유입니다.
이 예제에서 age에 valueAsNumber: true를 지정한 이유도 여기에 있습니다. 이 옵션이 없으면 Zod가 문자열 "25"를 받아 Expected number, received string 에러를 냅니다. 반대로 옵션을 켠 상태에서 입력란을 비워 두면 값이 NaN이 되어 Expected number, received nan이라는, 사용자에게 보여 주기 어려운 메시지가 나옵니다. z.number({ invalid_type_error: '나이를 입력하세요' })로 메시지를 지정하거나, z.coerce.number()로 문자열을 숫자로 변환하는 방법이 있습니다. 다만 coerce는 빈 문자열을 0으로 바꾸므로 “입력하지 않음”과 “0 입력”을 구분해야 한다면 주의가 필요합니다.
watch와 setValue
watch
const { register, watch } = useForm();
const email = watch('email');
const allValues = watch();
useEffect(() => {
console.log('Email changed:', email);
}, [email]);
watch는 비제어 방식의 이점을 스스로 포기하는 API라는 점을 알고 써야 합니다. watch('email')을 호출한 컴포넌트는 email이 바뀔 때마다 다시 렌더링되고, 인자 없는 watch()는 어떤 필드든 바뀔 때마다 폼 전체를 다시 렌더링합니다. React Hook Form을 도입했는데도 입력이 버벅인다면 가장 먼저 확인할 곳이 폼 최상위의 watch() 호출입니다. 특정 값을 보여 주는 작은 영역만 갱신하고 싶다면 그 영역을 별도 컴포넌트로 분리하고 useWatch({ control, name: 'email' })를 쓰면, 그 자식 컴포넌트만 다시 렌더링됩니다. 값이 바뀔 때 부수 효과만 실행하면 된다면 watch((values, { name }) => { ... })처럼 콜백 형태로 구독하면 렌더링이 전혀 일어나지 않습니다.
setValue
const { register, setValue } = useForm();
const handleReset = () => {
setValue('email', '');
setValue('password', '');
};
setValue는 기본적으로 값만 바꾸고 검증, dirty 상태, touched 상태는 건드리지 않습니다. 필요하면 setValue('email', '', { shouldValidate: true, shouldDirty: true })처럼 옵션을 넘깁니다. 폼 전체를 초기 상태로 되돌리는 것이 목적이라면 위 예제처럼 필드마다 setValue를 호출하기보다 reset()을 쓰는 것이 맞습니다. reset은 값뿐 아니라 에러, dirty, 제출 여부까지 초기화하고, reset(newValues)로 새 기본값을 지정할 수도 있습니다.
서버에서 받은 데이터로 수정 폼을 채울 때 흔히 겪는 문제가 이것과 관련 있습니다. useForm({ defaultValues: data })에서 data가 아직 로딩 중이라 undefined였다면, 나중에 데이터가 도착해도 폼에 반영되지 않습니다. defaultValues는 처음 한 번만 읽히기 때문입니다. 데이터가 도착한 뒤 useEffect에서 reset(data)를 호출하거나, useForm({ values: data }) 옵션을 쓰면 값이 바뀔 때 폼이 갱신됩니다.
Controller로 외부 UI 컴포넌트 연결
register는 DOM의 ref와 네이티브 onChange 이벤트를 전제로 합니다. react-select, 날짜 선택기, MUI의 일부 컴포넌트처럼 내부에 실제 <input>이 없거나, onChange가 이벤트 대신 값을 넘기는 컴포넌트에는 register를 쓸 수 없습니다. Controller는 이런 컴포넌트를 제어 방식으로 연결하는 어댑터로, field 객체에 value, onChange, onBlur, name, ref를 담아 넘겨줍니다.
import { Controller } from 'react-hook-form';
import Select from 'react-select';
<Controller
name="country"
control={control}
rules={{ required: true }}
render={({ field }) => (
<Select
{...field}
options={[
{ value: 'us', label: 'United States' },
{ value: 'kr', label: 'South Korea' },
]}
/>
)}
/>
{...field}를 그대로 넘기면 react-select의 onChange가 넘기는 옵션 객체 전체({ value: 'kr', label: 'South Korea' })가 폼 값으로 저장됩니다. 서버에 'kr' 문자열만 보내고 싶었다면 onChange={(opt) => field.onChange(opt?.value)}와 value={options.find((o) => o.value === field.value)}처럼 변환해야 합니다. 폼 값의 형태와 UI 컴포넌트가 다루는 값의 형태가 다를 때 이 변환을 빠뜨리는 것이 Controller에서 가장 흔한 실수입니다. field.ref를 컴포넌트에 연결해 두면 검증 실패 시 해당 필드로 포커스가 이동합니다.
또 Controller로 연결된 필드는 제어 컴포넌트이므로, 이 필드의 값이 바뀌면 Controller 부분이 다시 렌더링됩니다. 폼 전체가 아니라 해당 필드만 렌더링되므로 보통 문제가 없지만, 컴포넌트 자체가 무겁다면 React.memo로 감싸는 것이 도움이 됩니다. 예제의 control은 useForm()이 반환한 값을 꺼내 둔 것이라고 가정합니다.
useFieldArray로 배열 필드
import { useFieldArray } from 'react-hook-form';
export default function DynamicForm() {
const { register, control, handleSubmit } = useForm({
defaultValues: {
items: [{ name: '', quantity: 0 }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
<input type="number" {...register(`items.${index}.quantity`)} />
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', quantity: 0 })}>
Add Item
</button>
<button type="submit">Submit</button>
</form>
);
}
useFieldArray는 배열 필드의 추가, 삭제, 순서 변경을 관리합니다. 반드시 key={field.id}를 써야 하는데, 이 id는 React Hook Form이 항목마다 생성한 고유 값입니다. key={index}를 쓰면 중간 항목을 삭제했을 때 React가 DOM 요소를 재사용하면서, 비제어 입력에 남아 있던 값이 다른 항목의 입력란에 표시되는 버그가 생깁니다. 제어 컴포넌트에서는 드러나지 않던 문제가 비제어 방식에서는 바로 보이는 대표적인 경우입니다.
append에는 항목 전체의 기본값을 넘겨야 합니다. append({})처럼 빈 객체를 넘기면 새 항목의 입력이 undefined로 시작해 제출 데이터에 필드가 빠질 수 있습니다. 예제의 quantity도 type="number"지만 valueAsNumber가 없어 문자열로 제출되므로, 실제 코드라면 register(`items.${index}.quantity`, { valueAsNumber: true })로 바꿔야 합니다. 또 field 객체에 담긴 값은 항목이 추가되던 시점의 스냅샷이라, 현재 입력값을 화면에 표시하려면 field의 값을 읽지 말고 useWatch로 구독해야 합니다.
중첩 객체 스키마를 쓰는 복잡한 폼 예제
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
personalInfo: z.object({
firstName: z.string().min(2),
lastName: z.string().min(2),
email: z.string().email(),
}),
address: z.object({
street: z.string(),
city: z.string(),
zipCode: z.string().regex(/^\d{5}$/),
}),
preferences: z.object({
newsletter: z.boolean(),
notifications: z.boolean(),
}),
});
type FormData = z.infer<typeof schema>;
export default function ComplexForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<h2>Personal Info</h2>
<input {...register('personalInfo.firstName')} />
{errors.personalInfo?.firstName && <span>{errors.personalInfo.firstName.message}</span>}
<input {...register('personalInfo.lastName')} />
<input {...register('personalInfo.email')} />
<h2>Address</h2>
<input {...register('address.street')} />
<input {...register('address.city')} />
<input {...register('address.zipCode')} />
<h2>Preferences</h2>
<label>
<input type="checkbox" {...register('preferences.newsletter')} />
Newsletter
</label>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}
점(.)으로 구분한 이름 personalInfo.firstName은 제출 데이터에서 중첩 객체로 만들어지므로, Zod 스키마의 중첩 구조와 그대로 맞물립니다. onSubmit이 Promise를 반환하면 handleSubmit이 끝날 때까지 기다리며 그동안 isSubmitting이 true가 되므로, 예제처럼 중복 제출 방지 버튼을 간단히 만들 수 있습니다.
이 예제를 그대로 실행하면 제출 버튼을 눌러도 아무 일도 일어나지 않습니다. 스키마에는 preferences.notifications: z.boolean()이 있지만 폼에 해당 체크박스가 없어서 값이 undefined가 되고, Zod가 Required 에러를 내는데 이 에러를 화면에 표시하는 코드도 없기 때문입니다. lastName, email, 주소 필드도 에러 출력이 빠져 있어 같은 현상이 생길 수 있습니다. 스키마와 화면이 어긋날 때 흔히 겪는 상황으로, 디버깅할 때는 handleSubmit(onSubmit, (errors) => console.log(errors))로 실패 콜백을 찍어 보면 원인이 바로 보입니다. 해결하려면 체크박스를 추가하거나 useForm의 defaultValues에 preferences: { newsletter: false, notifications: false }를 넣어 두면 됩니다. 사실 모든 필드에 defaultValues를 지정하는 것이 권장 방식인데, 그래야 reset()이 돌아갈 기준이 생기고 dirty 판정도 정확해집니다.
React Hook Form 요약
- React Hook Form: 고성능 폼 라이브러리
- 빠른 성능: 최소 리렌더링
- Validation: 내장 검증
- Zod 통합: 타입 안전성
- Controller: 커스텀 컴포넌트
- 배열 필드: useFieldArray
구현 체크리스트
- React Hook Form 설치
- 기본 폼 구현
- Validation 추가
- Zod 통합
- Controller 사용
- 배열 필드 구현
- 에러 처리
같이 보면 좋은 글
- Zod 기본기: 스키마 정의와 타입 추론, 문자열·숫자·객체·배열 검증, union과 스키마 조합
- shadcn/ui: 설치 대신 소스를 복사하는 컴포넌트 키트, 테마·다크 모드, CLI, 커스터마이징
- React 가이드
자주 묻는 질문 (FAQ)
Q. Formik과 비교하면 어떤가요?
A. Formik은 모든 값을 React 상태로 관리하는 제어 방식이라 필드가 많은 폼에서 입력마다 렌더링이 늘어납니다. React Hook Form은 비제어 방식으로 이 비용이 작고, 최근 업데이트도 더 활발한 편입니다. 이미 Formik으로 잘 돌아가는 폼을 굳이 옮길 필요는 없지만, 새 프로젝트라면 React Hook Form을 고르는 경우가 많습니다.
Q. shadcn/ui와 함께 사용할 수 있나요?
A. shadcn/ui의 Form 컴포넌트는 React Hook Form의 Controller와 FormProvider를 감싼 형태라 함께 쓰도록 설계되어 있습니다.
Q. 성능은 어떤가요?
A. 비제어 입력 덕분에 타이핑 중 리렌더링이 거의 없습니다. 다만 폼 최상위에서 watch()를 호출하거나 formState 전체를 구조 분해 없이 읽으면 이점이 사라지므로, 필요한 상태만 꺼내 쓰고 값 구독은 useWatch로 하위 컴포넌트에 둡니다.
Q. 서버 검증 에러는 어떻게 표시하나요?
A. 제출 후 서버가 필드별 에러를 돌려주면 setError('email', { type: 'server', message: '이미 가입된 이메일입니다' })로 해당 필드에 에러를 넣습니다. 폼 전체 에러는 setError('root.serverError', ...)로 넣고 errors.root에서 읽을 수 있습니다.