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
valueANDonChange— React warns about uncontrolled→controlled switch. defaultValue+value— conflicting. Pick one.- Re-creating schema in render — re-runs validation pointlessly. Define outside.
- Forgetting
keyprop 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 .