Controlled vs uncontrolled inputs: who owns the value, and what it costs
Controlled inputs put the value in React state and re-render per keystroke; uncontrolled inputs leave it in the DOM, read via refs or FormData on submit. Each wins in different forms — and a value prop without onChange, or switching modes mid-life, produces production bugs.
Support tickets started at 9:14 on a Tuesday: “the coupon field is broken — I type and nothing appears.” The checkout team could not reproduce it, because their dev build drowned the signal in noise; the console had been shouting for two sprints: You provided a value prop to a form field without an onChange handler. This will render a read-only field. During a refactor, someone had lifted the coupon code into Redux to show it in the order summary, wired value from the store — and deleted the local onChange as “dead code”, because the summary still updated in their manual test (they pasted via the browser autofill, which they then also broke). The input was now controlled by a value that nothing ever updated: React dutifully reset the DOM back to the stale store value after every keystroke. Three lines of code, eleven days in production, a measurable dip in coupon redemptions. The bug class has a name — the half-controlled input — and it exists because React has two ownership models for form state, and the component silently degrades when you pick neither.
Two owners, one input
Every form field has exactly one source of truth, and React makes you choose which. In a controlled input, React state owns the value: the value prop pins what the DOM shows, and onChange is the only door through which a keystroke becomes visible — the event fires, you call setState, React re-renders, and the new value flows back down into the DOM. The DOM input is reduced to a dumb display of your state. In an uncontrolled input, the DOM owns the value, exactly as it would in a plain HTML page: the browser tracks what the user typed, React never hears about individual keystrokes, and you read the result when you need it — via a ref, or all at once with new FormData(form) in the submit handler. defaultValue (not value) seeds the initial text without claiming ownership.
The distinction is not stylistic; it decides when your code runs. Controlled means your component re-renders on every keystroke — that is the price of admission. Uncontrolled means your component renders once and your code runs at submit time.
function CouponControlled() {
const [code, setCode] = useState("");
// Re-renders per keystroke; can validate, transform, disable live.
return (
<input
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
/>
);
}
function CheckoutUncontrolled() {
// Renders once; the DOM holds the values until submit.
function handleSubmit(e) {
e.preventDefault();
const data = new FormData(e.currentTarget);
submitOrder(data.get("coupon"), data.get("email"));
}
return (
<form onSubmit={handleSubmit}>
<input name="coupon" defaultValue="" />
<input name="email" type="email" />
<button>Pay</button>
</form>
);
}What controlled buys you, and what it bills you
Choose controlled when the UI must react to the value as it changes: live validation messages, a character counter, formatting-as-you-type (card numbers in groups of four), filtering a list on each keystroke, enabling the submit button only when the form is valid, or keeping two widgets in sync with the same value. None of that is possible if React only learns the value at submit.
The bill arrives as render work. One controlled input re-rendering its own small component is nothing — a render is a function call producing elements, well under a millisecond. The failure mode is structural: teams lift all field state into one object at the top of a 60-field form, so every keystroke in any field re-renders the entire form tree. At 5–15 ms per full-form render that still fits a frame; add a few expensive child components and you reach 40–80 ms per keystroke — and typing visibly stutters, because each keystroke now triggers layout-sized work. The fixes are also structural: keep field state in the smallest component that needs it, memoize heavy siblings, or stop controlling fields that nothing reads before submit.
Choose uncontrolled when the form is submit-shaped: nothing depends on intermediate values, you want minimal re-render cost, you are integrating a non-React widget that mutates the DOM itself, or you handle file inputs — <input type="file" /> is always uncontrolled, because its value can only be set by the user, never by code.
▸Why this works
Why does React force the choice at all, when plain HTML just lets the DOM hold the value? Because React’s core contract is that the UI is a function of state — and a DOM node that mutates itself on every keystroke is state living outside that function. Controlled inputs restore the contract by demoting the DOM to a projection of React state. Uncontrolled inputs keep the DOM’s native behavior and accept that this one piece of state is invisible to rendering. Both are legitimate; what is illegitimate is an input where the two owners fight — which is precisely the half-controlled bug.
The half-controlled input: how the modes corrupt each other
Two failure modes account for almost every form bug in this area. First: value without onChange. The input becomes controlled by a constant. The user types, the DOM briefly shows the character, React re-renders (or just resets the node) back to the pinned value — the field eats keystrokes. React warns once in the console and offers the legal escapes: add onChange, use readOnly if it is genuinely read-only, or use defaultValue if you only meant to seed it.
Second: switching modes during the component’s life. An input mounts uncontrolled (value is undefined — perhaps the profile data has not loaded yet) and later receives a string once the fetch resolves. React logs: A component is changing an uncontrolled input to be controlled. The danger is not the warning text; it is that whatever the user typed during the uncontrolled window is silently obliterated when the value prop takes ownership. The inverse switch (controlled → uncontrolled, value becomes undefined) abandons the field with its last value frozen. The rule: an input is controlled or uncontrolled for its whole lifetime. If your initial state may be absent, initialize to "", never undefined — or do not render the input until the data exists.
A profile form initializes with const [name, setName] = useState(user.name), where user.name is undefined until a fetch resolves, then becomes a string. The input has value={name} and a correct onChange. What actually goes wrong?
A 60-field insurance form keeps all fields in one useState object at the form root; typing in any field lags visibly. What is the mechanism of the lag?
- 01Trace a single keystroke through a controlled input and through an uncontrolled input — what code runs, when, and who holds the value at each step?
- 02Name the two half-controlled failure modes, their user-visible symptoms, and the rule that prevents both.
React gives a form field two possible owners and requires you to pick one. Controlled: the value prop pins the DOM to React state, onChange is the only way a keystroke becomes visible, and the loop keystroke → setState → re-render runs per character. That buys everything that needs the live value — validation as the user types, formatting, character counters, dependent buttons — and bills you a re-render per keystroke, which is negligible for one small component and very real when a 60-field form keeps all state at the root and re-renders the whole tree per character; the cure is colocating field state, memoizing heavy siblings, or not controlling what nothing reads. Uncontrolled: the DOM owns the value with native browser behavior, React runs nothing while the user types, and you read the result once at submit through a ref or FormData; defaultValue seeds without owning, and file inputs are always uncontrolled. The two production bug classes are ownership fights: value without onChange controls the input with a constant so it eats keystrokes (React’s console warning names it exactly), and a value that transitions between undefined and a string flips the input’s mode mid-life, destroying whatever the user typed in the gap. Initialize controlled state to an empty string, never undefined, and hold one mode for the input’s whole lifetime. Now when you see a form field silently swallowing keystrokes or a console warning about an uncontrolled-to-controlled flip, you know exactly which ownership rule was broken and what the one-line fix is.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.