10 KiB
10 KiB
id, title, category, status, source_trust_level, verification_status, created_at, updated_at, tags, tech_stack, applied_in, aliases
| id | title | category | status | source_trust_level | verification_status | created_at | updated_at | tags | tech_stack | applied_in | aliases | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| frontend-form-state-deep | Form State — RHF / TanStack Form / Conform | Coding | draft | B | conceptual | 2026-05-09 | 2026-05-09 |
|
|
|
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)
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<typeof schema>;
function LoginForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
await api.login(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register('password')} />
{errors.password && <p>{errors.password.message}</p>}
<button disabled={isSubmitting}>Submit</button>
</form>
);
}
→ Uncontrolled (DOM 가 source). Re-render 적음.
Field array (dynamic)
const { register, control } = useForm<{ items: { name: string }[] }>();
const { fields, append, remove } = useFieldArray({ control, name: 'items' });
return (
<>
{fields.map((field, i) => (
<div key={field.id}>
<input {...register(`items.${i}.name`)} />
<button onClick={() => remove(i)}>Delete</button>
</div>
))}
<button onClick={() => append({ name: '' })}>Add</button>
</>
);
Watch (subscribe)
const { watch } = useForm();
const password = watch('password');
// Watch all
const all = watch();
→ 매 변경 = re-render. 사용 자제.
useController (Custom field)
import { useController } from 'react-hook-form';
function CustomInput({ control, name }) {
const { field, fieldState } = useController({ control, name });
return <input {...field} />;
}
→ Class library / 외부 component (MUI 등).
Async validation
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)
const { reset } = useForm({ defaultValues: async () => fetchUser() });
// → 자동 reset 후 정착.
Submit error
const onSubmit = async (data) => {
try {
await api.login(data);
} catch (e) {
setError('email', { message: 'Wrong credentials' });
}
};
TanStack Form (modern)
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 (
<form onSubmit={(e) => { e.preventDefault(); form.handleSubmit(); }}>
<form.Field
name="email"
validators={{ onBlur: z.string().email() }}
children={(field) => (
<>
<input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
{field.state.meta.errors.length > 0 && <p>{field.state.meta.errors[0]}</p>}
</>
)}
/>
</form>
);
→ Modern, type-safe, framework-agnostic.
Conform (server action 친화)
'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 (
<form id={form.id} action={loginAction}>
<input name={fields.email.name} />
<p>{fields.email.errors}</p>
<button>Submit</button>
</form>
);
}
→ Server Action / Remix native.
Zod schema
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<typeof userSchema>;
Conditional fields
const schema = z.discriminatedUnion('type', [
z.object({ type: z.literal('email'), email: z.string().email() }),
z.object({ type: z.literal('phone'), phone: z.string() }),
]);
const type = watch('type');
{type === 'email' && <input {...register('email')} />}
{type === 'phone' && <input {...register('phone')} />}
Multi-step form
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 (
<form onSubmit={handleSubmit(onSubmit)}>
{step === 1 && <input {...register('email')} />}
{step === 2 && <input {...register('password')} />}
<button type="button" onClick={next}>Next</button>
</form>
);
Form persist (refresh 시)
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
const onSubmit = async (data) => {
// Local update 즉시
setItems([...items, data]);
try {
await api.create(data);
} catch {
// Rollback
setItems(items);
}
};
Server Action (RSC)
// 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');
}
// app/login/page.tsx
import { loginAction } from '../actions';
export default function Page() {
return (
<form action={loginAction}>
<input name="email" />
<button>Submit</button>
</form>
);
}
→ React 19 / Next.js. JS 없이도 form 가 work.
useActionState (React 19)
'use client';
import { useActionState } from 'react';
const [state, action, isPending] = useActionState(loginAction, null);
return (
<form action={action}>
<input name="email" />
{state?.error && <p>{state.error}</p>}
<button disabled={isPending}>Submit</button>
</form>
);
→ 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
<input
{...register('email')}
aria-invalid={!!errors.email}
aria-describedby="email-error"
/>
{errors.email && (
<p id="email-error" role="alert">{errors.email.message}</p>
)}
→ 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)
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
const { register } = useForm<{ avatar: FileList }>();
<input type="file" {...register('avatar')} />
const onSubmit = ({ avatar }) => {
const file = avatar[0];
const formData = new FormData();
formData.append('file', file);
fetch('/upload', { method: 'POST', body: formData });
};
Error from server
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 (작은).