이 글은 누구를 위한 것인가
- 복잡한 폼에서 타입 안전성과 성능을 동시에 원하는 팀
- 서버 에러를 폼 필드에 연결하는 방법을 찾는 개발자
useFieldArray로 동적으로 추가/삭제되는 폼 필드를 구현하려는 팀
들어가며
React Hook Form은 비제어 컴포넌트 방식으로 리렌더링을 최소화하고, Zod는 TypeScript 타입과 런타임 검증을 통합한다. 둘의 결합은 "타입 추론 → 스키마 검증 → 에러 표시"를 완전 자동화한다.
이 글은 bluefoxdev.kr의 React 폼 관리 완전 가이드 를 참고하여 작성했습니다.
1. 기본 설정과 핵심 패턴
// 설치
// pnpm add react-hook-form zod @hookform/resolvers
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// 1. Zod 스키마 정의 (진실의 근원)
const productSchema = z.object({
name: z.string()
.min(2, '상품명은 2자 이상 입력해주세요')
.max(100, '상품명은 100자 이하로 입력해주세요'),
price: z.coerce.number() // 문자열 → 숫자 자동 변환
.positive('가격은 0보다 커야 합니다')
.multipleOf(0.01, '소수점 2자리까지 입력 가능합니다'),
category: z.enum(['electronics', 'clothing', 'food'], {
errorMap: () => ({ message: '카테고리를 선택해주세요' }),
}),
description: z.string().optional(),
isPublished: z.boolean().default(false),
tags: z.array(z.string().min(1)).max(10, '태그는 최대 10개까지'),
// 조건부 검증
discountRate: z.number().min(0).max(100).optional(),
}).refine(
(data) => !data.discountRate || data.price > 0,
{ message: '할인율을 설정하려면 가격이 필요합니다', path: ['discountRate'] }
);
// 2. 타입 자동 추론 (별도 interface 불필요)
type ProductFormData = z.infer<typeof productSchema>;
// 3. 폼 훅 설정
function ProductForm({ onSubmit }: { onSubmit: (data: ProductFormData) => Promise<void> }) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isDirty },
setError, // 서버 에러 설정
reset,
watch, // 실시간 값 감시
setValue,
} = useForm<ProductFormData>({
resolver: zodResolver(productSchema),
defaultValues: {
name: '',
price: 0,
category: 'electronics',
isPublished: false,
tags: [],
},
});
const hasDiscountRate = watch('discountRate') !== undefined;
async function handleFormSubmit(data: ProductFormData) {
try {
await onSubmit(data);
reset();
} catch (error) {
if (error instanceof ApiError) {
// 서버 에러를 특정 필드에 연결
setError('name', {
type: 'server',
message: error.fieldErrors.name,
});
// 전체 폼 에러
setError('root', {
type: 'server',
message: '서버 오류가 발생했습니다',
});
}
}
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} noValidate>
<div>
<label htmlFor="name">상품명 *</label>
<input
id="name"
{...register('name')}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<p id="name-error" role="alert">{errors.name.message}</p>
)}
</div>
<div>
<label htmlFor="price">가격 *</label>
<input id="price" type="number" {...register('price')} />
{errors.price && <p role="alert">{errors.price.message}</p>}
</div>
<div>
<label htmlFor="category">카테고리 *</label>
<select id="category" {...register('category')}>
<option value="">선택해주세요</option>
<option value="electronics">전자제품</option>
<option value="clothing">의류</option>
<option value="food">식품</option>
</select>
{errors.category && <p role="alert">{errors.category.message}</p>}
</div>
{errors.root && (
<p role="alert" className="text-red-500">{errors.root.message}</p>
)}
<button type="submit" disabled={isSubmitting || !isDirty}>
{isSubmitting ? '저장 중...' : '저장'}
</button>
</form>
);
}
// useFieldArray - 동적 필드 (상품 옵션 추가/삭제)
import { useFieldArray, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const orderSchema = z.object({
customerId: z.string().cuid(),
items: z.array(z.object({
productId: z.string().cuid(),
quantity: z.coerce.number().int().positive(),
note: z.string().optional(),
})).min(1, '최소 1개 이상의 상품을 추가해주세요'),
});
type OrderFormData = z.infer<typeof orderSchema>;
function OrderForm() {
const { register, handleSubmit, control, formState: { errors } } = useForm<OrderFormData>({
resolver: zodResolver(orderSchema),
defaultValues: {
customerId: '',
items: [{ productId: '', quantity: 1 }],
},
});
const { fields, append, remove, move } = useFieldArray({
control,
name: 'items',
});
return (
<form onSubmit={handleSubmit(console.log)}>
<input {...register('customerId')} placeholder="고객 ID" />
<h3>주문 상품</h3>
{fields.map((field, index) => (
<div key={field.id}> {/* field.id가 안정적인 key */}
<select {...register(`items.${index}.productId`)}>
<option value="">상품 선택</option>
{/* 상품 목록 */}
</select>
<input
type="number"
{...register(`items.${index}.quantity`)}
min="1"
/>
{errors.items?.[index]?.quantity && (
<p>{errors.items[index].quantity?.message}</p>
)}
<button
type="button"
onClick={() => remove(index)}
disabled={fields.length === 1}
>
삭제
</button>
</div>
))}
{errors.items?.root && (
<p role="alert">{errors.items.root.message}</p>
)}
<button
type="button"
onClick={() => append({ productId: '', quantity: 1 })}
>
상품 추가
</button>
<button type="submit">주문 생성</button>
</form>
);
}
class ApiError extends Error {
fieldErrors: Record<string, string> = {};
}
// Controller로 커스텀 컴포넌트 통합
import { Controller } from 'react-hook-form';
import DatePicker from 'react-datepicker';
function EventForm() {
const { control, handleSubmit } = useForm({
resolver: zodResolver(z.object({
title: z.string().min(1),
startDate: z.date(),
category: z.string(),
})),
});
return (
<form onSubmit={handleSubmit(console.log)}>
{/* 커스텀 DatePicker - register 불가, Controller 사용 */}
<Controller
name="startDate"
control={control}
render={({ field, fieldState }) => (
<div>
<DatePicker
selected={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
dateFormat="yyyy/MM/dd"
/>
{fieldState.error && <p>{fieldState.error.message}</p>}
</div>
)}
/>
</form>
);
}
// 성능 최적화: watch는 리렌더링 유발
// 특정 필드만 구독
function PriceDisplay() {
const price = useWatch({ name: 'price' }); // watch 대신 useWatch
return <p>총액: ₩{price?.toLocaleString()}</p>;
}
마무리
React Hook Form + Zod의 핵심 가치는 z.infer<typeof schema>로 타입과 검증을 단일 소스로 유지하는 것이다. setError('root')로 서버 에러를 폼에 통합하고, useFieldArray로 동적 필드를 관리하면 대부분의 복잡한 폼 요구사항을 커버할 수 있다. 성능 최적화: watch() 대신 useWatch()를 사용하고, 검증이 필요 없는 필드는 mode: 'onBlur'로 설정하면 타이핑 중 불필요한 검증을 줄일 수 있다.