--- id: react-rhf-zod-patterns title: React Hook Form + Zod — 폼 / 검증 / 에러 category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [react, react-hook-form, zod, form, vibe-coding] tech_stack: { language: "TS / React", applicable_to: ["Frontend"] } applied_in: [] aliases: [RHF, useForm, zodResolver, register, Controller, server validation] --- # React Hook Form + Zod > 가벼운 + 빠른 폼. **Zod schema 가 진실 source — type 도 검증도**. RHF + zodResolver + shadcn/ui Form 이 표준 조합. ## 📖 핵심 개념 - Uncontrolled by default: ref 기반, re-render 적음. - Controller: 외부 컴포넌트 wrap (MUI / Radix). - Zod schema: type infer + parse + 에러 포맷. - defaultValues: undefined 안 넣기. ## 💻 코드 패턴 ### 기본 ```tsx 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), remember: z.boolean().default(false), }); type Form = z.infer; function Login() { const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm
({ resolver: zodResolver(schema), defaultValues: { email: '', password: '', remember: false }, }); const onSubmit = async (data: Form) => { await api.login(data); }; return ( {errors.email && {errors.email.message}} {errors.password && {errors.password.message}}
); } ``` ### Controller (외부 컴포넌트) ```tsx import { Controller } from 'react-hook-form'; import { Select } from '@radix-ui/react-select'; ( )} /> ``` ### Server-side errors ```tsx const onSubmit = async (data: Form) => { try { await api.login(data); } catch (e) { if (e.code === 'EMAIL_TAKEN') { setError('email', { type: 'server', message: '이미 사용중' }); } else { setError('root.serverError', { message: '서버 오류' }); } } }; // root error 표시 {errors.root?.serverError &&

{errors.root.serverError.message}

} ``` ### Field array (동적 list) ```tsx const { control, register } = useForm({ defaultValues: { items: [{ name: '', qty: 1 }] } }); const { fields, append, remove } = useFieldArray({ control, name: 'items' }); {fields.map((f, i) => (
))} ``` ### Watch + computed ```tsx const password = watch('password'); const strength = useMemo(() => calcStrength(password), [password]); ``` ### Conditional schema ```ts const schema = z.discriminatedUnion('type', [ z.object({ type: z.literal('email'), email: z.string().email() }), z.object({ type: z.literal('phone'), phone: z.string().regex(/^\d{10}$/) }), ]); ``` ### shadcn/ui Form ```tsx
( Email )} /> ``` ### Server schema 공유 (Backend + Frontend) ```ts // shared package export const LoginSchema = z.object({...}); // frontend: zodResolver(LoginSchema) // backend: LoginSchema.parse(req.body) ``` ### Trigger validation manually ```tsx const valid = await trigger('email'); if (valid) goNextStep(); ``` ## 🤔 의사결정 기준 | 상황 | 추천 | |---|---| | 일반 폼 | RHF + zodResolver | | 서버 컴포넌트 (Next 14+) | Server actions + zod-form-data | | 매우 간단 | useState 충분 | | Wizard / multi-step | RHF + form context | | File upload | RHF + Controller | | Real-time validation | mode: 'onChange' (debounce 권장) | ## ❌ 안티패턴 - **defaultValues undefined**: uncontrolled→controlled warning. 빈 문자열. - **Type 직접 정의 — schema 따로**: drift. infer 활용. - **Controller 안 써도 되는데 사용**: re-render 비용. - **모든 onChange validation**: 빠른 입력 시 jank. mode: 'onBlur' 또는 'onTouched'. - **mutate 매번 reset 안 함**: 다음 폼에 stale 값. - **숫자 input string 으로**: `valueAsNumber: true`. - **Server validation 없음**: client 만 — bypass 가능. ## 🤖 LLM 활용 힌트 - RHF + zodResolver + shadcn Form 3종. - Schema = 한 곳 (frontend/backend 공유). - Server error → setError('root.serverError'). ## 🔗 관련 문서 - [[React_Form_State_Patterns]] - [[AI_Structured_Output_Zod]]