--- id: frontend-form-state-deep title: Form State — RHF / TanStack Form / Conform category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [frontend, form, react, vibe-coding] tech_stack: { language: "TS / React", applicable_to: ["Frontend"] } applied_in: [] aliases: [react-hook-form, RHF, TanStack Form, Conform, Formik, Zod, validation, form library] --- # Form State Library > Form state 가 React 의 가장 boilerplate. **react-hook-form (가장 인기), TanStack Form (modern), Conform (server action 친화)**. Zod / Yup 가 schema. ## 📖 핵심 개념 - Uncontrolled (RHF) > controlled (re-render ↓). - Schema validation (Zod / Yup / Valibot). - Async validation. - Field array (dynamic list). ## 💻 코드 패턴 ### react-hook-form (RHF) ```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), }); type FormData = z.infer; function LoginForm() { const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({ resolver: zodResolver(schema), }); const onSubmit = async (data: FormData) => { await api.login(data); }; return (
{errors.email &&

{errors.email.message}

} {errors.password &&

{errors.password.message}

}
); } ``` → Uncontrolled (DOM 가 source). Re-render 적음. ### Field array (dynamic) ```tsx const { register, control } = useForm<{ items: { name: string }[] }>(); const { fields, append, remove } = useFieldArray({ control, name: 'items' }); return ( <> {fields.map((field, i) => (
))} ); ``` ### Watch (subscribe) ```tsx const { watch } = useForm(); const password = watch('password'); // Watch all const all = watch(); ``` → 매 변경 = re-render. 사용 자제. ### `useController` (Custom field) ```tsx import { useController } from 'react-hook-form'; function CustomInput({ control, name }) { const { field, fieldState } = useController({ control, name }); return ; } ``` → Class library / 외부 component (MUI 등). ### Async validation ```ts const schema = z.object({ email: z.string().email().refine(async (email) => { const exists = await api.checkEmail(email); return !exists; }, 'Email already exists'), }); ``` → Zod 가 async refine. ### Default values (async) ```tsx const { reset } = useForm({ defaultValues: async () => fetchUser() }); // → 자동 reset 후 정착. ``` ### Submit error ```tsx const onSubmit = async (data) => { try { await api.login(data); } catch (e) { setError('email', { message: 'Wrong credentials' }); } }; ``` ### TanStack Form (modern) ```tsx import { useForm } from '@tanstack/react-form'; import { z } from 'zod'; const form = useForm({ defaultValues: { email: '', password: '' }, onSubmit: async ({ value }) => { await api.login(value); }, validatorAdapter: zodValidator, }); return (
{ e.preventDefault(); form.handleSubmit(); }}> ( <> field.handleChange(e.target.value)} onBlur={field.handleBlur} /> {field.state.meta.errors.length > 0 &&

{field.state.meta.errors[0]}

} )} /> ); ``` → Modern, type-safe, framework-agnostic. ### Conform (server action 친화) ```tsx 'use client'; import { useForm } from '@conform-to/react'; import { parseWithZod } from '@conform-to/zod'; import { loginAction } from './actions'; export default function LoginForm() { const [form, fields] = useForm({ onValidate({ formData }) { return parseWithZod(formData, { schema: loginSchema }); }, }); return (

{fields.email.errors}

); } ``` → Server Action / Remix native. ### Zod schema ```ts const userSchema = z.object({ email: z.string().email().min(1, 'Required'), age: z.number().int().min(18).max(100), role: z.enum(['admin', 'user']), bio: z.string().optional(), tags: z.array(z.string()).max(5), }).refine(d => d.email !== 'admin@x.com', 'Not allowed'); type User = z.infer; ``` ### Conditional fields ```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() }), ]); ``` ```tsx const type = watch('type'); {type === 'email' && } {type === 'phone' && } ``` ### Multi-step form ```tsx const [step, setStep] = useState(1); const { register, handleSubmit, trigger } = useForm({ shouldUnregister: false }); const next = async () => { // Validate current step const valid = await trigger(['email']); // step 1 fields if (valid) setStep(2); }; return (
{step === 1 && } {step === 2 && }
); ``` ### Form persist (refresh 시) ```ts useEffect(() => { const saved = localStorage.getItem('form'); if (saved) reset(JSON.parse(saved)); }, []); const values = watch(); useEffect(() => { localStorage.setItem('form', JSON.stringify(values)); }, [values]); ``` ### Optimistic UI ```tsx const onSubmit = async (data) => { // Local update 즉시 setItems([...items, data]); try { await api.create(data); } catch { // Rollback setItems(items); } }; ``` ### Server Action (RSC) ```tsx // app/actions.ts 'use server'; import { z } from 'zod'; const schema = z.object({ email: z.string().email() }); export async function loginAction(formData: FormData) { const data = schema.parse(Object.fromEntries(formData)); await db.users.create(data); redirect('/dashboard'); } ``` ```tsx // app/login/page.tsx import { loginAction } from '../actions'; export default function Page() { return (
); } ``` → React 19 / Next.js. JS 없이도 form 가 work. ### useActionState (React 19) ```tsx 'use client'; import { useActionState } from 'react'; const [state, action, isPending] = useActionState(loginAction, null); return (
{state?.error &&

{state.error}

}
); ``` → Server action + client UX. ### Performance ``` Controlled (useState): 매 keystroke = re-render. Uncontrolled (RHF): blur / submit 만 re-render. → 큰 form (50+ field) 가 RHF 의 큰 차이. ``` ### Validation timing ``` - onBlur (default): user 가 떠나면. - onChange: 매 keystroke (긴 form 에 X). - onSubmit: submit 만. → Field 별 다름: - Email: onBlur. - Password strength: onChange. - Form-level: onSubmit. ``` ### Accessibility ```tsx {errors.email && ( )} ``` → Screen reader 가 error 읽음. ### Library 비교 ``` RHF: 가장 인기, mature, ecosystem 큰. TanStack Form: modern, framework-agnostic. Conform: server action 친화. Formik: legacy (RHF 가 추월). ``` ### Validator 비교 ``` Zod: TS-first, 큰 ecosystem. Yup: legacy, JS 친화. Valibot: 작은 bundle (10x 작음 보다 Zod), modern. ArkType: 빠른 + complex. Joi: Node-friendly. ``` ### Custom validation (Zod) ```ts const schema = z.object({ password: z.string() .min(8) .refine(s => /[A-Z]/.test(s), 'Need uppercase') .refine(s => /\d/.test(s), 'Need digit') .refine(s => /[!@#$]/.test(s), 'Need special'), confirm: z.string(), }).refine(d => d.password === d.confirm, { message: 'Passwords mismatch', path: ['confirm'], }); ``` ### File upload ```tsx const { register } = useForm<{ avatar: FileList }>(); const onSubmit = ({ avatar }) => { const file = avatar[0]; const formData = new FormData(); formData.append('file', file); fetch('/upload', { method: 'POST', body: formData }); }; ``` ### Error from server ```ts const onSubmit = async (data) => { try { await api.submit(data); } catch (e) { if (e.fields) { Object.entries(e.fields).forEach(([name, msg]) => { setError(name, { message: msg }); }); } else { setError('root', { message: e.message }); } } }; ``` ## 🤔 의사결정 기준 | 상황 | 추천 | |---|---| | React + 일반 | react-hook-form + Zod | | Modern type-safe | TanStack Form | | Server action | Conform | | 큰 bundle 안 됨 | Valibot | | Multi-step | RHF + step state | | File upload | RHF + FormData | | Async validate | Zod refine async | ## ❌ 안티패턴 - **모든 거 controlled (useState 매 input)**: 큰 form = 느린. - **No schema**: server / client validation 다름. - **Validate onChange (긴 form)**: jittery. - **No server-side validate**: 신뢰 X. - **Watch all + render**: 폭발. - **No accessibility**: a11y 깨짐. - **No optimistic UI**: 느린 UX. ## 🤖 LLM 활용 힌트 - RHF + Zod 가 default React stack. - TanStack Form 가 modern (framework-agnostic). - Conform 가 server action 친화. - Validator = Zod (TS) / Valibot (작은). ## 🔗 관련 문서 - [[React_Form_State_Patterns]] - [[React_RHF_Zod_Patterns]] - [[TS_Schema_Validation_Comparison]]