Controlled vs uncontrolled inputs in React: which one and when
June 30, 2026 · 7 min read

Almost every React dev hits this moment: you render an input, set its value, and then... you can't type in it. The field is frozen. The fix is everywhere online, but the reason comes down to one question that defines this whole topic:
Who owns the input's value — React, or the DOM?
That's the entire distinction. Get that, and controlled vs uncontrolled stops being jargon.
Uncontrolled: the DOM owns the value
This is how plain HTML inputs work. The browser keeps the value internally; you read it when you need it. In React, you set an initial value with defaultValue and reach for the current value with a ref:
function Form() {
const inputRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
console.log(inputRef.current.value); // read it when you need it
}
return (
<form onSubmit={handleSubmit}>
<input defaultValue="" ref={inputRef} />
<button>Submit</button>
</form>
);
}
React isn't tracking each keystroke here. The DOM holds the truth, and you ask for it on submit. Note defaultValue, not value — that sets the starting value once without taking ownership.
Controlled: React owns the value
Here the value lives in state, and the input simply reflects it. Every keystroke fires onChange, which updates state, which re-renders the input with the new value:
function Form() {
const [name, setName] = useState("");
return (
<form>
<input value={name} onChange={(e) => setName(e.target.value)} />
</form>
);
}
The value flows down from state and changes flow up through onChange. React is the single source of truth — the input can't hold a value React doesn't know about.
The famous frozen-input bug
This is the frozen input, and it's the same misunderstanding every time:
<input value={name} /> {/* no onChange */}
You told React it owns the value (value={name}), but gave it no way to update that value when you type. So React does exactly what you asked: it pins the input to name and ignores your keystrokes. React even warns you — "you provided a value prop without an onChange handler... this will render a read-only field." The field isn't broken; it's doing precisely what a controlled input with no update path must do.
Three valid fixes, depending on intent:
- Add
onChange— you wanted controlled - Switch
valuetodefaultValue— you wanted uncontrolled - Add
readOnly— you genuinely wanted it read-only
There's a sibling bug too: starting with value={undefined} and later giving it a real value flips the input from uncontrolled to controlled mid-life, and React warns about that. The fix is to always initialize controlled state to "", never undefined.
So when do you use which?
The deciding question: do you need the value while the user is typing, or only when they're done?
Reach for controlled when you need to react to the value as it changes:
// live validation, formatting, conditional UI, disabling submit
<input value={email} onChange={(e) => setEmail(e.target.value)} />
{email && !email.includes("@") && <span>Enter a valid email</span>}
Anything that responds mid-typing — live validation, character counts, formatting as you type, enabling/disabling a button, fields that depend on each other — needs controlled, because React has to know the value on every keystroke to render the response.
Reach for uncontrolled when you only need the value at the end. Modern React makes this cleaner than refs with FormData:
function handleSubmit(e) {
e.preventDefault();
const data = new FormData(e.target);
console.log(data.get("email")); // no state, no refs
}
return (
<form onSubmit={handleSubmit}>
<input name="email" />
<button>Submit</button>
</form>
);
Simple forms where you just collect values on submit are a great fit — no state, no re-renders, less code.
Where the difference becomes something you can feel
The distinction sounds academic until you put expensive work in the render path. Imagine a dashboard with a search input alongside a component that does expensive computation on every render:
function UserIds() {
const ids = Array.from({ length: 5000 }, () => Math.random().toString(36).substring(2, 11));
return (
<ul>
{ids.map((id) => (
<li key={id}>
<strong>{id}</strong>
</li>
))}
</ul>
);
}
export default function Dashboard() {
const [search, setSearch] = useState("");
return (
<>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<UserIds /> {/* re-renders on every keystroke */}
</>
);
}
Type into this and it feels sticky. Here's the chain: each keystroke → onChange → setSearch → re-render → <UserIds /> re-renders and runs Array.from(…) again, generating 5000 random IDs synchronously → React paints. The real cost is that Array.from computation blocking the main thread on every keystroke — not the rendering itself. Because <UserIds /> sits in the same render path as the input, the input can't update until the component finishes its work — so your typing visibly lags behind your keystrokes.
The same field as uncontrolled doesn't have this problem — but it's important to understand why:
function Dashboard() {
const inputRef = useRef(null);
return (
<>
<input defaultValue="" ref={inputRef} />
<UserIds /> {/* never re-renders on keystroke */}
</>
);
}
Typing is instant now — but not because uncontrolled inputs are magically faster. It's because the DOM updates the field on its own without a React re-render, so <UserIds /> simply never re-renders while you type. The lesson isn't "uncontrolled is faster" — it's "don't hang expensive work on every keystroke."
That reframe matters, because controlled inputs are usually the right choice, and you don't have to abandon them to fix lag. If <UserIds /> doesn't depend on the search value — say you filter server-side on submit — wrapping it in memo stops it re-rendering whenever the parent state changes:
const UserIds = memo(function UserIds() {
const ids = Array.from({ length: 5000 }, () => Math.random().toString(36).substring(2, 11));
return (
<ul>
{ids.map((id) => (
<li key={id}>
<strong>{id}</strong>
</li>
))}
</ul>
);
});
function Dashboard() {
const [search, setSearch] = useState("");
return (
<>
<input value={search} onChange={(e) => setSearch(e.target.value)} />
<UserIds /> {/* props haven't changed — memo skips the re-render */}
</>
);
}
Now typing stays snappy. memo makes React skip re-rendering <UserIds /> unless its props change — and since it receives none, every keystroke that updates search skips the re-render entirely, which means the Array.from computation never runs either.
The real takeaway: the lag isn't controlled-vs-uncontrolled — it's expensive work on every render. Uncontrolled sidesteps it by not re-rendering at all; controlled fixes it by stopping unnecessary re-renders with memo. Either is valid, but knowing which problem you're actually solving keeps you from reaching for uncontrolled inputs when the real fix is right there in React's toolbox.
The honest rule of thumb
Default to controlled when the form does something as the user types, and uncontrolled (or FormData) when you just need the values at the end. Controlled gives you full control at the cost of a re-render per keystroke — fine for almost every form, occasionally worth avoiding on very large ones. Uncontrolled is lighter and closer to the platform, but the value lives outside React until you ask for it. And file inputs are uncontrolled whether you like it or not.
It's less "which is better" and more "who should own this value for what I'm building" — and once you ask it that way, the choice is usually obvious.