Files
2nd/10_Wiki/Topics/Coding/Frontend_Form_State_Deep.md
T
2026-05-10 22:08:15 +09:00

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
frontend
form
react
vibe-coding
language applicable_to
TS / React
Frontend
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)

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 (작은).

🔗 관련 문서