---
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]]
