Skip to main content

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, 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

AspectControlledUncontrolledReact 19 Actions
State lives inReact stateDOM node via refServer action function
Re-renders on changeYes, every keystrokeNoNo (server-side)
Validation timingReal-time via onChangeOn submit or blurServer + client via useActionState
Code per fieldState + handler per fieldOne ref per formOne action function
Best forDynamic UI, live errorsFile uploads, large formsNext.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, 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.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?