Frontend / React / 19_controlled_vs_uncontrolled.md

Controlled vs uncontrolled components

Updated 4 interview angles 4 min read source
On this page7
  1. The two modes
  2. Choosing
  3. The mixed approach, and where React 19 pushes it
  4. Validation placement
  5. Where it breaks in production
  6. Related
  7. Interview angle

Controlled vs uncontrolled components

The question is really about who owns the input’s value — React state, or the DOM. It sounds like a beginner topic and interviewers keep asking it because the wrong default causes real problems at scale: a fifty-field form where every keystroke re-renders the page, or a component that flips from one mode to the other and throws a warning nobody understands.

The two modes

tsx
// Controlled: React state is the source of truth. Every keystroke
// runs through React.
const [name, setName] = useState("");
<input value={name} onChange={(e) => setName(e.target.value)} />

// Uncontrolled: the DOM holds the value. React reads it when it needs to.
const ref = useRef<HTMLInputElement>(null);
<input defaultValue="" ref={ref} />
// later: ref.current!.value
Controlled Uncontrolled
Value lives in React state the DOM node
Re-renders on every change never, until read
Instant validation trivial possible, awkward
Reset / prefill set state defaultValue or .value =
Bulk forms costly cheap

value makes an input controlled; defaultValue leaves it uncontrolled. That one prop is the entire switch, and mixing them on one element is the source of the famous warning.

Choosing

Controlled when the UI reacts to the value as it is typed: live validation, a character counter, a search box that filters a list, an input whose format you enforce (a masked phone number), or anything where two inputs depend on each other.

Uncontrolled when you only need the value at submit time and nothing watches it meanwhile: most plain forms. It is also the only option for <input type="file"> — its value is read-only for security, so it cannot be controlled, and the same is true of anything integrating a non-React widget that manages its own DOM.

The performance argument is real but easy to overstate. A controlled input re-renders its component on every keystroke; that is fine for ten fields and noticeable for two hundred, or when the component tree above it is heavy. Measure before restructuring.

The mixed approach, and where React 19 pushes it

Uncontrolled inputs plus a form-level read on submit is the shape most large forms end up in. FormData reads every field at once, so there is no ref bookkeeping:

tsx
function Signup() {
  async function submit(formData: FormData) {
    const email = formData.get("email") as string;
    await api.signup({ email });
  }
  return (
    <form action={submit}>
      <input name="email" type="email" required />
      <button>Sign up</button>
    </form>
  );
}

React 19’s form Actions make this the default: <form action={fn}> hands the function FormData, useActionState gives you pending and error state, and the browser’s own validation attributes (required, type="email", pattern) do the first pass for free. Detail in React 19 — Actions, useActionState, useOptimistic, use(), ref as Prop.

Controlled inputs still belong in that form for the fields that need to be watched. The mix is normal; what is not normal is defaulting every field to controlled out of habit.

Validation placement

Validation is the usual reason people reach for controlled, and it is worth separating the two questions.

  • When to validate — on change, on blur, or on submit. On-change is the noisiest and the only one that requires controlled state. On-blur catches most errors with far less churn.
  • Where the rule lives — inline in the component, or in a schema (Zod, Valibot) that the server also uses. A shared schema is the answer that scales, and it is indifferent to controlled vs uncontrolled.

Form libraries exist to make this choice for you: React Hook Form is uncontrolled by default with per-field subscriptions, and it is the reason a 300-field form does not lag. Formik is controlled. Knowing which your library is explains its performance profile.

Where it breaks in production

  • Switching modes. An input rendered with value={undefined} and then a string flips from uncontrolled to controlled, and React warns. It usually means state was initialised as undefined instead of "".
  • Controlled input with no onChange. It becomes read-only, silently, because React resets the DOM to the state value on every render.
  • defaultValue that changes. It is read once at mount; updating the prop later does nothing. Remount with a key to reset an uncontrolled form.
  • Reading a ref during render. ref.current is populated after commit; read it in handlers and effects.
  • A file input in state. Cannot be done; store the File from the change event and render the input uncontrolled.
  • Every field controlled in a large form. The page lags on typing, and the fix is not memoisation, it is ownership.

Interview angle 4

  • “Controlled or uncontrolled?” - who owns the value. Controlled when the UI reacts as the user types - live validation, dependent fields, a filter; uncontrolled when you only need it at submit, which is most forms. File inputs cannot be controlled at all.
  • “What does the controlled/uncontrolled warning mean?” - an input’s value went from undefined to a string, so React saw it change mode. Almost always state initialised as undefined instead of an empty string.
  • “Why is a 200-field controlled form slow?” - every keystroke re-renders the owning component, and with the state high in the tree, a lot below it. The fix is ownership, not useMemo: uncontrolled fields read at submit, or a library like React Hook Form that subscribes per field.
  • “How do React 19 Actions change this?” - <form action> receives FormData, so the uncontrolled-plus-read-on-submit pattern becomes the default, with pending and error state from useActionState. Follow-up: does that mean never controlled? No - controlled for the fields you watch; the mix is the normal shape.