폼은 프론트엔드에서 가장 귀찮은 영역이다. 상태 관리, 유효성 검사, 에러 메시지, 서버 제출, 동적 필드 추가/삭제. 간단한 로그인 폼도 제대로 만들려면 코드가 길어지고, 필드가 20개짜리 주문 폼이 되면 눈이 아파진다.
React Hook Form(이하 RHF)을 1년 넘게 쓰면서 축적한 패턴들을 정리한다. 공식 문서에 나오는 기본적인 내용은 빠르게 넘기고, 실무에서 마주치는 상황 위주로 쓴다.
기본: register
import { useForm } from 'react-hook-form';
interface LoginForm {
email: string;
password: string;
}
function LoginPage() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginForm>();
const onSubmit = async (data: LoginForm) => {
await login(data.email, data.password);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email', {
required: '이메일을 입력해주세요',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: '올바른 이메일 형식이 아닙니다',
},
})}
placeholder="이메일"
/>
{errors.email && <span className="error">{errors.email.message}</span>}
<input
type="password"
{...register('password', {
required: '비밀번호를 입력해주세요',
minLength: {
value: 8,
message: '8자 이상 입력해주세요',
},
})}
placeholder="비밀번호"
/>
{errors.password && <span className="error">{errors.password.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '로그인 중...' : '로그인'}
</button>
</form>
);
}register는 uncontrolled input 방식으로 동작한다. useState로 각 필드를 관리하지 않으니 리렌더링이 적다. 필드가 많을수록 이 차이가 커진다.
Controller: 서드파티 UI 라이브러리
직접 만든 <input>이면 register로 충분하지만, MUI의 <TextField>나 Radix의 <Select> 같은 서드파티 컴포넌트는 ref를 내부 input에 직접 연결할 수 없는 경우가 있다. 그때 Controller를 쓴다.
import { Controller, useForm } from 'react-hook-form';
import { Select } from '@radix-ui/react-select';
import DatePicker from 'react-datepicker';
interface OrderForm {
product: string;
quantity: number;
deliveryDate: Date;
paymentMethod: 'card' | 'bank' | 'cash';
}
function OrderFormPage() {
const { control, handleSubmit } = useForm<OrderForm>();
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="deliveryDate"
control={control}
rules={{ required: '배송일을 선택해주세요' }}
render={({ field, fieldState })=> (
<div>
<DatePicker
selected={field.value}
onChange={field.onChange}
minDate={new Date()}
placeholderText="배송일 선택"
/>
{fieldState.error && (
<span className="error">{fieldState.error.message}</span>
)}
</div>
)}
/>
<Controller
name="paymentMethod"
control={control}
rules={{ required: '결제 수단을 선택해주세요' }}
render={({ field })=> (
<Select value={field.value} onValueChange={field.onChange}>
<Select.Trigger>결제 수단</Select.Trigger>
<Select.Content>
<Select.Item value="card">카드</Select.Item>
<Select.Item value="bank">계좌이체</Select.Item>
<Select.Item value="cash">현금</Select.Item>
</Select.Content>
</Select>
)}
/>
</form>
);
}Controller의 render prop에서 field 객체를 받으면 value, onChange, onBlur, ref가 들어있다. 이걸 서드파티 컴포넌트의 props에 연결하면 된다.
Zod와 연동
유효성 검사 로직이 복잡해지면 register의 rules만으로는 한계가 있다. Zod 스키마로 분리하면 깔끔하다.
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
const signupSchema = z.object({
email: z
.string()
.min(1, '이메일을 입력해주세요')
.email('올바른 이메일 형식이 아닙니다'),
password: z
.string()
.min(8, '8자 이상 입력해주세요')
.regex(/[A-Z]/, '대문자를 포함해야 합니다')
.regex(/[0-9]/, '숫자를 포함해야 합니다'),
confirmPassword: z.string(),
agreeToTerms: z.literal(true, {
errorMap: () => ({ message: '약관에 동의해주세요' }),
}),
}).refine((data) => data.password === data.confirmPassword, {
message: '비밀번호가 일치하지 않습니다',
path: ['confirmPassword'],
});
type SignupForm = z.infer<typeof signupSchema>;
function SignupPage() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
});
// ...
}
z.infer<typeof signupSchema>로 스키마에서 타입을 추출할 수 있다. 스키마와 타입이 항상 일치한다. refine으로 비밀번호 확인 같은 필드 간 검증도 가능하다.
useFieldArray: 동적 필드
주문 폼에서 상품을 여러 개 추가할 수 있는 경우.
import { useForm, useFieldArray } from 'react-hook-form';
interface OrderForm {
customerName: string;
items: {
productId: string;
quantity: number;
note?: string;
}[];
}
function OrderForm() {
const { control, register, handleSubmit } = useForm<OrderForm>({
defaultValues: {
items: [{ productId: '', quantity: 1 }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('customerName')} placeholder="주문자명" />
{fields.map((field, index) => (
<div key={field.id} className="item-row">
<select {...register(`items.${index}.productId` as const)}>
<option value="">상품 선택</option>
<option value="p1">상품 A</option>
<option value="p2">상품 B</option>
</select>
<input
type="number"
{...register(`items.${index}.quantity` as const, {
valueAsNumber: true,
min: 1,
})}
/>
<input
{...register(`items.${index}.note` as const)}
placeholder="비고"
/>
{fields.length > 1 && (
<button type="button" onClick={()=> remove(index)}>
삭제
</button>
)}
</div>
))}
<button
type="button"
onClick={()=> append({ productId: '', quantity: 1 })}
>
상품 추가
</button>
<button type="submit">주문</button>
</form>
);
}주의: fields.map에서 key로 field.id를 써야 한다. index를 key로 쓰면 삭제/추가 시 필드 값이 꼬인다. RHF가 내부적으로 생성하는 id를 써야 올바르게 추적된다.
watch와 조건부 필드
특정 필드의 값에 따라 다른 필드가 나타나거나 사라지는 경우.
function DeliveryForm() {
const { register, watch, control } = useForm<DeliveryForm>();
const deliveryType = watch('deliveryType');
return (
<form>
<select {...register('deliveryType')}>
<option value="standard">일반 배송</option>
<option value="pickup">매장 픽업</option>
<option value="express">퀵배송</option>
</select>
{deliveryType === 'standard' && (
<>
<input {...register('address', { required: true })} placeholder="주소" />
<input {...register('detailAddress')} placeholder="상세주소" />
</>
)}
{deliveryType= 'pickup' && (
<select {...register('storeId', { required: true })}>
<option value="">매장 선택</option>
<option value="s1">강남점</option>
<option value="s2">홍대점</option>
</select>
)}
{deliveryType === 'express' && (
<>
<input {...register('address', { required: true })} placeholder="주소" />
<Controller
name="expressTime"
control={control}
rules={{ required: true }}
render={({ field })=> (
<TimePicker value={field.value} onChange={field.onChange} />
)}
/>
</>
)}
</form>
);
}watch는 리렌더링을 발생시킨다. 필드가 많은 폼에서 불필요한 watch를 남발하면 성능에 영향을 줄 수 있다. 특정 필드만 watching하려면 watch('deliveryType') 처럼 필드명을 지정한다.
서버 에러 처리
서버에서 내려온 에러를 폼 필드에 매핑하는 패턴.
const { setError, handleSubmit } = useForm<SignupForm>();
const onSubmit = async (data: SignupForm) => {
try {
await signup(data);
} catch (error) {
if (error instanceof ApiError) {
// 필드별 에러
if (error.field === 'email') {
setError('email', {
type: 'server',
message: '이미 사용 중인 이메일입니다',
});
}
// 전체 폼 에러
if (!error.field) {
setError('root', {
type: 'server',
message: error.message,
});
}
}
}
};
// root 에러 표시
{errors.root && (
<div className="form-error">{errors.root.message}</div>
)}setError('root', ...)는 특정 필드가 아닌 폼 전체에 대한 에러를 설정한다. "서버 오류가 발생했습니다" 같은 일반적인 에러 메시지를 보여줄 때 쓴다.
성능 팁
RHF가 빠른 이유는 uncontrolled 방식이라서 입력할 때마다 리렌더링이 발생하지 않기 때문이다. 하지만 잘못 쓰면 이 장점이 사라진다.
// 나쁜 패턴: 전체를 watch하면 어떤 필드가 바뀌어도 리렌더링
const allValues = watch();
// 좋은 패턴: 필요한 필드만 watch
const deliveryType = watch('deliveryType');
// 더 좋은 패턴: 리렌더링 없이 값을 가져올 때
const { getValues } = useForm();
const handleClick = () => {
const email = getValues('email'); // 리렌더링 없음
};formState에서 errors만 구조분해하면 에러 상태가 바뀔 때만 리렌더링된다. formState 전체를 가져오면 isSubmitting, isDirty 등이 바뀔 때마다 리렌더링된다.
폼이 복잡해질수록 RHF의 진가가 나온다. 필드 20개짜리 폼을 controlled 방식으로 만들면 한 글자 입력할 때마다 20개 필드가 전부 리렌더링되는데, RHF는 입력 중인 필드만 업데이트된다. 복잡한 폼에서 타이핑이 버벅거리는 경험을 한 적 있다면, 대부분 이 리렌더링 문제다. RHF로 바꾸면 바로 체감된다.
