Forms and form handling in React
React form handling comes down to one decision: does React or the DOM own the input value? That choice determines when data is available, how often components re-render, and how much code you write.
Theory
TL;DR
- Controlled: React state holds every input value,
onChangesyncs it on every keystroke - Uncontrolled: the DOM holds the value,
useRefreads it only at submit - Analogy: controlled is a puppet on strings (React pulls every move); uncontrolled is a locked safe (React opens it only when needed)
- Live validation or conditional fields → controlled; file inputs, large forms, third-party libs → uncontrolled
- React 19 adds server actions:
useActionStatehandles submit and server mutations without a separate API route
Quick example
// Controlled: React owns the value
const [value, setValue] = useState("");
<input value={value} onChange={(e) => setValue(e.target.value)} />
// Re-renders on every keystroke
// Uncontrolled: DOM owns it, ref reads it at submit
const ref = useRef<HTMLInputElement>(null);
<input ref={ref} />
const submitted = ref.current?.value; // read only when neededControlled wires value and onChange into a feedback loop. Uncontrolled skips the loop entirely.
Key difference
In a controlled input, the value prop locks what the user sees to React state. Type a character, onChange fires, setState runs, React queues a reconciler update via scheduleUpdateOnFiber, and the input re-renders with the new value. In an uncontrolled input, the DOM manages its own displayed text. useRef attaches to the host DOM node during the commit phase and you call ref.current.value only when you actually need the data, usually on submit.
When to use
- Email field that shows "invalid format" while the user is still typing → controlled
- Fields that appear only when another input has a specific value → controlled
- Forms with 100+ fields where per-keystroke re-renders cause noticeable lag → uncontrolled
<input type="file">→ always uncontrolled (browsers block settingvalueon file inputs for security reasons)- Third-party inputs like React-Select that manage their own state → uncontrolled
- Full-stack Next.js with direct DB writes → React 19 server actions
Comparison table
| Aspect | Controlled | Uncontrolled | React 19 Actions |
|---|---|---|---|
| State lives in | React state | DOM node via ref | Server action function |
| Re-renders on change | Yes, every keystroke | No | No (server-side) |
| Validation timing | Real-time via onChange | On submit or blur | Server + client via useActionState |
| Code per field | State + handler per field | One ref per form | One action function |
| Best for | Dynamic UI, live errors | File uploads, large forms | Next.js RSC + DB mutations |
How React processes form input internally
When the user types in a controlled input, the browser fires an input event, React's synthetic onChange picks it up, setState queues a fiber update, and the component re-renders with the updated value prop. V8 bails out if props haven't changed.
For uncontrolled inputs, useRef attaches to the host DOM node during the commit phase (ReactFiberCommitWork). No scheduler is involved. Reading ref.current.value goes straight to the DOM. That's why uncontrolled forms have almost zero overhead per keystroke.
React 19 adds a third model. A form with an action function serializes its fields into FormData on submit and sends a POST to the server action. useActionState returns [state, action, isPending], so pending and error states come built in, no manual useState needed.
Common mistakes
Forgetting e.preventDefault()
// Wrong: native submit fires, page reloads, React state is gone
const handleSubmit = (e) => { login(data); };
// Correct
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
login(data);
};Mutating state directly in onChange
// Wrong: same object reference, React sees no change, no re-render
const [data, setData] = useState({ name: "" });
// onChange={(e) => { data.name = e.target.value; setData(data); }}
// Correct: new reference triggers re-render
onChange={(e) => setData({ ...data, name: e.target.value })}React compares object references, not deep values. Mutating in place keeps the same reference, so the update is invisible.
Trying to control a file input
// Wrong: browser ignores value on file inputs
<input type="file" value={selectedFile} onChange={...} />
// Correct: always uncontrolled for files
const fileRef = useRef<HTMLInputElement>(null);
<input type="file" ref={fileRef} />Reading ref.current before mount
// Wrong: ref is null in the render phase
const ref = useRef(null);
const value = ref.current?.value; // null here, always
// Correct: read inside a submit handler or event callbackReact 19 action without FormData
Server actions receive FormData, not JSON. JSON.stringify breaks file uploads because FileList cannot be serialized to JSON.
// Wrong
submitAction(JSON.stringify(formData));
// Correct
const data = new FormData(e.currentTarget as HTMLFormElement);
submitAction(data);Real-world usage
- React Hook Form: uncontrolled by default (uses refs internally), integrates Zod and Yup, the default choice in most production codebases
- Formik: controlled with
<Field>, used in Shopify and Atlassian products - Next.js app router:
"use server"functions handle form submissions and DB mutations in one place, no separate API route - React Final Form: uncontrolled observables, common in Salesforce-adjacent apps
- Zod + tRPC: schema validation in
onChangefor type-safe full-stack apps
From what I've seen on production projects: teams that start with raw controlled components for complex forms almost always migrate to React Hook Form within a month. Starting with it saves that step entirely.
Follow-up questions
Q: What's the performance cost of a 200-field controlled form?
A: Each keystroke triggers a state update and a reconciler pass over the component subtree. Fix options: useDeferredValue to deprioritize updates, splitting state closer to each field with useState, or switching to uncontrolled and reading only on submit.
Q: How does useActionState differ from a manual useState + fetch pattern?
A: useActionState integrates with the React scheduler and <form action={...}> directly. You get isPending without a separate loading state, the server action runs on the server without an API route, and errors surface as a return value in the state tuple.
Q: Why can't you set value on <input type="file">?
A: Browser security. A script that could preset file values could silently select files and send them to a server. The spec blocks value assignment on file inputs entirely.
Q: How do you handle async validation without blocking the input?
A: Mark the validation update with useTransition so it's non-urgent. The input stays responsive while the async check runs in the background. Add useDebounce to avoid firing a network request on every single keystroke.
Q: Design a form with optimistic updates, server actions, and rollback on error. What race condition can appear?
A: Use useOptimistic for the instant UI update and useActionState for the server action. Sequence: submit triggers useOptimistic (result shows immediately) → server action fires → on success React reconciles; on error, throwing in the action triggers rollback. Race condition: user submits twice before the first action finishes. The second optimistic update overwrites the first, then the first server response arrives and the state is inconsistent. Fix: disable submit with isPending from useFormStatus while an action is in flight.
Examples
Controlled login form with live validation
import { useState } from "react";
function LoginForm() {
const [formData, setFormData] = useState({ email: "", password: "" });
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (data: typeof formData) => {
const next: Record<string, string> = {};
if (!data.email.includes("@")) next.email = "Invalid email";
if (data.password.length < 8) next.password = "At least 8 characters";
setErrors(next);
return Object.keys(next).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (validate(formData)) {
await fetch("/api/login", {
method: "POST",
body: JSON.stringify(formData),
});
}
};
return (
<form onSubmit={handleSubmit}>
<input
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="Email"
/>
{errors.email && <span>{errors.email}</span>}
<input
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
/>
{errors.password && <span>{errors.password}</span>}
<button type="submit">Login</button>
</form>
);
}Both fields share one state object. The spread in onChange creates a new reference so React sees the change. validate runs on submit and blocks the fetch call if anything fails.
Uncontrolled file upload with React 19 useActionState
import { useRef, useActionState } from "react";
async function uploadAction(_: unknown, formData: FormData) {
const files = formData.getAll("files") as File[];
// await uploadToS3(files);
return { uploaded: files.length }; // { uploaded: 2 }
}
function FileUpload() {
const fileRef = useRef<HTMLInputElement>(null);
const [state, submitAction, isPending] = useActionState(uploadAction, null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// FormData picks up the file input by its name attribute
const data = new FormData(e.currentTarget as HTMLFormElement);
submitAction(data);
};
return (
<form onSubmit={handleSubmit}>
<input ref={fileRef} name="files" type="file" multiple />
<button type="submit" disabled={isPending}>
{isPending ? "Uploading..." : "Upload"}
</button>
{state && <p>Uploaded {state.uploaded} file(s)</p>}
</form>
);
}new FormData(form) collects the file input by its name attribute. useActionState tracks pending state, so there is no separate useState for the loading flag. The action returns a value and React surfaces it in state.
Multi-field form with useReducer
type FormState = {
name: string;
email: string;
role: string;
errors: Record<string, string>;
};
type FormAction =
| { type: "SET_FIELD"; field: string; value: string }
| { type: "SET_ERROR"; field: string; error: string }
| { type: "RESET" };
function formReducer(state: FormState, action: FormAction): FormState {
switch (action.type) {
case "SET_FIELD":
return { ...state, [action.field]: action.value };
case "SET_ERROR":
return {
...state,
errors: { ...state.errors, [action.field]: action.error },
};
case "RESET":
return { name: "", email: "", role: "", errors: {} };
}
}
function RegistrationForm() {
const [state, dispatch] = useReducer(formReducer, {
name: "",
email: "",
role: "",
errors: {},
});
return (
<form>
<input
value={state.name}
onChange={(e) =>
dispatch({ type: "SET_FIELD", field: "name", value: e.target.value })
}
/>
{state.errors.name && <span>{state.errors.name}</span>}
</form>
);
}SET_FIELD handles any input by field name, so adding a new field requires no new useState. RESET clears everything in one dispatch. This pattern scales to 10-15 fields; beyond that, React Hook Form is the better call.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.