| name | form-handling |
| description | React Hook Form + Zod 유효성 검증, 제어/비제어 폼 패턴, 서버 액션 연동 |
Form Handling — React Hook Form + Zod
소스: https://react-hook-form.com/docs | https://zod.dev/
검증일: 2026-04-01
왜 React Hook Form인가
| 일반 useState 폼 | React Hook Form |
|---|
| 리렌더링 | 입력마다 발생 | 최소화 (비제어) |
| 유효성 검증 | 수동 구현 | 스키마 기반 자동 |
| 에러 처리 | 직접 관리 | 자동 |
| 코드량 | 많음 | 적음 |
기본 설정
pnpm add react-hook-form zod @hookform/resolvers
Zod 스키마 + RHF 연동
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const SignupSchema = z.object({
email: z.string().email('올바른 이메일을 입력해주세요'),
password: z
.string()
.min(8, '비밀번호는 8자 이상이어야 합니다')
.regex(/[A-Z]/, '대문자를 포함해야 합니다'),
passwordConfirm: z.string(),
age: z.number().min(14, '14세 이상만 가입 가능합니다').optional(),
}).refine(
data => data.password === data.passwordConfirm,
{ message: '비밀번호가 일치하지 않습니다', path: ['passwordConfirm'] }
)
type SignupFormData = z.infer<typeof SignupSchema>
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm<SignupFormData>({
resolver: zodResolver(SignupSchema),
defaultValues: { email: '', password: '', passwordConfirm: '' },
})
async function onSubmit(data: SignupFormData) {
try {
await signup(data)
} catch (err) {
setError('email', { message: '이미 사용 중인 이메일입니다' })
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input {...register('email')} placeholder="이메일" />
{errors.email && <p role="alert">{errors.email.message}</p>}
</div>
<div>
<input type="password" {...register('password')} />
{errors.password && <p role="alert">{errors.password.message}</p>}
</div>
<div>
<input type="password" {...register('passwordConfirm')} />
{errors.passwordConfirm && <p role="alert">{errors.passwordConfirm.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '처리 중...' : '가입하기'}
</>
)
}
재사용 가능한 필드 컴포넌트 (캡슐화)
import { type FieldError, type UseFormRegisterReturn } from 'react-hook-form'
import styles from './FormField.module.scss'
interface FormFieldProps {
label: string
registration: UseFormRegisterReturn
error?: FieldError
type?: string
placeholder?: string
}
function FormField({ label, registration, error, type = 'text', placeholder }: FormFieldProps) {
const id = registration.name
return (
<div className={styles.field}>
<label htmlFor={id} className={styles.label}>{label}</label>
<input
id={id}
type={type}
placeholder={placeholder}
className={error ? }
=
= ? `${}` }
{}
/>
{error && (
{error.message}
)}
)
}
<
label=
registration={()}
error={errors.}
=
/>
복잡한 폼 — useFieldArray (동적 필드)
import { useFieldArray } from 'react-hook-form'
const Schema = z.object({
members: z.array(z.object({
name: z.string().min(1, '이름을 입력해주세요'),
role: z.string(),
})).min(1, '최소 1명이 필요합니다'),
})
function TeamForm() {
const { control, register, formState: { errors } } = useForm({
resolver: zodResolver(Schema),
defaultValues: { members: [{ name: '', role: '' }] },
})
const { fields, append, remove } = useFieldArray({ control, name: 'members' })
return (
<form>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`members.${index}.name`)} placeholder="이름" />
{errors.members?.[index]?.name && (
{errors.members[index].name.message}
)}
remove(index)}>삭제
))}
append({ name: '', role: '' })}>
멤버 추가
)
}
React 19 Server Actions 연동
'use server'
import { z } from 'zod'
const Schema = z.object({ email: z.string().email() })
export async function submitForm(formData: FormData) {
const result = Schema.safeParse({ email: formData.get('email') })
if (!result.success) {
return { error: result.error.flatten() }
}
return { success: true }
}
function ContactForm() {
const [serverError, setServerError] = useState<string | null>(null)
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(Schema),
})
async function () {
formData = ()
.(data).( formData.(k, (v)))
result = (formData)
(result.) {
(result..[] ?? result...?.[] ?? )
}
}
(
)
}
Zod 패턴 모음
z.string().optional()
z.string().transform(v => v === '' ? undefined : v).optional()
z.coerce.number().min(0).max(100)
z.string().datetime()
z.coerce.date()
z.enum(['admin', 'user', 'guest'])
z.discriminatedUnion('type', [
z.object({ type: z.literal('email'), email: z.string().email() }),
z.object({ type: z.literal('phone'), phone: z.string().min(10) }),
])