[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
---
|
||||
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<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)
|
||||
```tsx
|
||||
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)
|
||||
```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 <input {...field} />;
|
||||
}
|
||||
```
|
||||
|
||||
→ 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 (
|
||||
<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 친화)
|
||||
```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 (
|
||||
<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
|
||||
```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<typeof userSchema>;
|
||||
```
|
||||
|
||||
### 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' && <input {...register('email')} />}
|
||||
{type === 'phone' && <input {...register('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 (
|
||||
<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 시)
|
||||
```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 (
|
||||
<form action={loginAction}>
|
||||
<input name="email" />
|
||||
<button>Submit</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
→ 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 (
|
||||
<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
|
||||
```tsx
|
||||
<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)
|
||||
```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 }>();
|
||||
|
||||
<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
|
||||
```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]]
|
||||
Reference in New Issue
Block a user