Suggest an editImprove this articleRefine the answer for “Forms and form handling in React”. Your changes go to moderation before they’re published.Approval requiredContentWhat you’re changing🇺🇸EN🇺🇦UAPreviewTitle (EN)Short answer (EN)**React form handling** uses controlled or uncontrolled components. Controlled: React state holds the value, `onChange` updates it on every keystroke. Uncontrolled: the DOM holds the value, `useRef` reads it at submit. ```tsx // Controlled const [email, setEmail] = useState(""); <input value={email} onChange={(e) => setEmail(e.target.value)} /> // Uncontrolled const ref = useRef<HTMLInputElement>(null); <input ref={ref} /> // ref.current?.value on submit ``` **Key rule:** controlled for live validation and conditional UI, uncontrolled for file inputs and large forms.Shown above the full answer for quick recall.Answer (EN)Image**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, `onChange` syncs it on every keystroke - Uncontrolled: the DOM holds the value, `useRef` reads 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: `useActionState` handles submit and server mutations without a separate API route ### Quick example ```tsx // 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 needed ``` Controlled 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 setting `value` on 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()`** ```tsx // 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`** ```tsx // 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** ```tsx // 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** ```tsx // 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 callback ``` **React 19 action without FormData** Server actions receive `FormData`, not JSON. `JSON.stringify` breaks file uploads because `FileList` cannot be serialized to JSON. ```tsx // 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 `onChange` for 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`](/questions/usestate-hook), 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 ```tsx 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 ```tsx 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 ```tsx 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.For the reviewerNote to the moderator (optional)Visible only to the moderator. Helps review go faster.