Forms cheatsheet.

Controlled

const [name, setName] = useState("");

<input value={name} onChange={(e) => setName(e.target.value)} />

React owns the value. Easy validation per keystroke but re-renders on every keypress.

Uncontrolled (refs)

const ref = useRef<HTMLInputElement>(null);

function onSubmit(e: React.FormEvent) {
  e.preventDefault();
  console.log(ref.current?.value);
}

<input ref={ref} defaultValue="initial" />

DOM owns the value. Cheaper, but harder to validate or react to input.

Native form via FormData

function onSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  const data = new FormData(e.currentTarget);
  console.log(data.get("name"), data.get("email"));
}

<form onSubmit={onSubmit}>
  <input name="name" defaultValue="..." />
  <input name="email" type="email" required />
  <button type="submit">Save</button>
</form>

Browser handles validation, FormData reads values.

React 19 form actions

async function action(formData: FormData) {
  "use server";   // or client-side
  await save(formData.get("name"));
}

<form action={action}>
  <input name="name" />
  <button>Save</button>
</form>

Works server-side (RSC) or client-side. With useFormStatus / useActionState.

useActionState

const [state, formAction, pending] = useActionState(
  async (prev: string, formData: FormData) => {
    const r = await save(formData);
    return r.ok ? "saved" : r.error;
  },
  "",
);

<form action={formAction}>
  <input name="x" />
  <button disabled={pending}>Save</button>
  <p>{state}</p>
</form>

React Hook Form

npm i react-hook-form
import { useForm } from "react-hook-form";

type Form = { name: string; email: string };

function MyForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<Form>();
  
  const onSubmit = (data: Form) => save(data);
  
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name", { required: true })} />
      {errors.name && <span>required</span>}
      
      <input {...register("email", { pattern: /^.+@.+$/ })} />
      
      <button>Save</button>
    </form>
  );
}

Uncontrolled under the hood — no re-render per keystroke.

RHF + Zod

npm i react-hook-form @hookform/resolvers zod
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";

const Schema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
  age: z.coerce.number().int().min(18),
});
type Form = z.infer<typeof Schema>;

const { register, handleSubmit, formState: { errors } } = useForm<Form>({
  resolver: zodResolver(Schema),
});

File inputs

const [file, setFile] = useState<File | null>(null);

<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />

// Multiple
<input type="file" multiple onChange={(e) => setFiles(Array.from(e.target.files ?? []))} />

// Upload
const data = new FormData();
data.append("file", file);
await fetch("/upload", { method: "POST", body: data });

Select / checkbox / radio

<select value={role} onChange={(e) => setRole(e.target.value)}>
  <option value="admin">Admin</option>
  <option value="user">User</option>
</select>

<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />

<input type="radio" name="size" value="s" checked={size === "s"} onChange={(e) => setSize(e.target.value)} />

Field arrays (RHF)

const { fields, append, remove } = useFieldArray({ control, name: "items" });

{fields.map((f, i) => (
  <div key={f.id}>
    <input {...register(`items.${i}.name` as const)} />
    <button onClick={() => remove(i)}>x</button>
  </div>
))}
<button onClick={() => append({ name: "" })}>add</button>

Server-side errors

const { setError } = useForm();

async function onSubmit(data: Form) {
  try {
    await save(data);
  } catch (e: any) {
    setError("email", { message: e.message });
  }
}

Debounced input

const [val, setVal] = useState("");
const debounced = useDeferredValue(val);

useEffect(() => {
  search(debounced);
}, [debounced]);

Common mistakes

  • Controlled without value AND onChange — React warns about uncontrolled→controlled switch.
  • defaultValue + value — conflicting. Pick one.
  • Re-creating schema in render — re-runs validation pointlessly. Define outside.
  • Forgetting key prop in field arrays — bound to wrong items after delete.
  • Not preventing default — full-page reload on submit.

Read this next

If you want my RHF + Zod recipes, they’re at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .