Skip to main content
react

Controlled vs Uncontrolled Is a UX Decision

Choosing between controlled and uncontrolled components is not just an implementation detail — it shapes how your form behaves for real users.


5 min read

Controlled vs uncontrolled is not an architecture purity argument — it is a decision about who owns the data and when. Use defaultValue when you trust the browser to manage field state and do not need to react to every keystroke. Use value when your UI has to respond to what the user types — dependent validation, conditional fields, external state sync. Most forms use the wrong one by default and pay for it in unnecessary complexity or poor performance.


When Uncontrolled Is the Right Call

An uncontrolled component stores its own state in the DOM. You hand it an initial value with defaultValue and read the final value on submit. This is how HTML forms have always worked, and for the majority of independent fields, it is still the right model.

Consider a user profile form with 20 fields. If you wire every field to React state, every keystroke re-renders the parent. With uncontrolled inputs, nothing re-renders until the user submits. React Hook Form's default mode uses uncontrolled inputs for exactly this reason — it registers refs, not state setters, and reads values on demand.

Uncontrolled inputs are also simpler to reason about. There is no synchronization problem. The value is wherever the user left it.

// Uncontrolled: read on submit, no per-keystroke state
function ProfileForm() {
  const nameRef = useRef<HTMLInputElement>(null);

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const name = nameRef.current?.value;
    // send to API
  }

  return (
    <form onSubmit={handleSubmit}>
      <input ref={nameRef} defaultValue="Luis" name="name" />
      <button type="submit">Save</button>
    </form>
  );
}

When Controlled Is Necessary

Controlled inputs earn their complexity when the UI has to react to the value mid-edit. Three clear cases:

Dependent validation. A "confirm password" field that compares against the first password field needs controlled state to run the comparison on every change.

Conditional rendering. If entering "company" into a role selector reveals a company-name field, you need to know the current value before the user submits.

External state sync. If a form field mirrors a URL param, a query string, or a global store, the field value must be driven from outside the DOM. defaultValue only sets the initial render — it does not respond to external changes after mount.

// Controlled: UI responds to typed value
function RoleForm() {
  const [role, setRole] = useState("individual");

  return (
    <form>
      <select value={role} onChange={(e) => setRole(e.target.value)}>
        <option value="individual">Individual</option>
        <option value="company">Company</option>
      </select>

      {role === "company" && (
        <input name="companyName" placeholder="Company name" />
      )}
    </form>
  );
}

The defaultValue vs value Mental Model

The distinction maps to a simple question: does the UI need to know the value before submit?

NeedUse
Just collect and senddefaultValue + ref or FormData
Show character countvalue + onChange
Enable submit only if validvalue + onChange
Pre-fill from URLvalue driven from state
Reset from external triggervalue driven from state

The mistake is defaulting to controlled because it feels "more React." Controlled state is a cost: every keystroke causes a render, every field needs an onChange handler, and the synchronization between the state and the DOM is your responsibility. Pay that cost only when you get something back.

Mixing Both Patterns

Most real forms use both. The approach that works well in practice: uncontrolled by default (via React Hook Form or native FormData), with controlled overrides only for the fields that need reactive behavior.

React Hook Form's watch() is a clean API for this hybrid: fields stay uncontrolled in the DOM, but you can subscribe to specific values when you need to respond to them without re-rendering the entire form.

const { register, watch } = useForm();
const role = watch("role"); // subscribes only this component to role changes

return (
  <form>
    <select {...register("role")}>
      <option value="individual">Individual</option>
      <option value="company">Company</option>
    </select>
    {role === "company" && <input {...register("companyName")} />}
  </form>
);

The pattern gives you the performance profile of uncontrolled with the reactivity of controlled, scoped exactly to the fields that need it. That scoping is the decision — not the library, not the pattern, but knowing which fields actually need to be reactive and which ones just need to be collected.