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.
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?
| Need | Use |
|---|---|
| Just collect and send | defaultValue + ref or FormData |
| Show character count | value + onChange |
| Enable submit only if valid | value + onChange |
| Pre-fill from URL | value driven from state |
| Reset from external trigger | value 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.