Lesson 54·M6 React Fundamentals
Controlled vs Uncontrolled Forms
OpenLearning Objectives
By the end of this lesson you should be able to:
- Explain the controlled/uncontrolled distinction in one sentence
- Build both versions, including a
ref-based uncontrolled input - Say exactly why controlled wins for validation, and what uncontrolled is for
- Debug the "input won't type" failure in thirty seconds
- Compare the two on read, sync, validation, and cost
One-Line Definition
A controlled input's value is owned by React state; an uncontrolled input's value is owned by the DOM.
That's the whole lesson. Controlled means value + onChange — state is the single source of truth and every keystroke goes through a re-render. Uncontrolled means the input keeps its own value in the DOM and React never looks at it until you ask.
Mental Model
Think of two filing systems for the same field.
- Controlled: the form keeps a master copy of every value; the input is just a window onto that copy. If the master copy doesn't change, the window can't — type all you want.
- Uncontrolled: the input is the copy. React doesn't hold one, so the field works on its own, and you reach into the DOM to read or set it when you need to.
One boss per value, is the rule to remember. A controlled input has exactly one boss (state); an uncontrolled input has one too (its own DOM node). The bugs in this lesson are all "two bosses" mistakes.
Visual Flow
Controlled:
user types "a"
│
▼
onChange("a") input's value is PROPS (state)
│ │
▼ │
setValue("a") │
│ │
▼ │
re-render ──► value="a" ◄────┘ state is the source of truth
Uncontrolled:
user types "a"
│
▼
DOM input.value = "a" React never re-renders
│
▼
you call ref.current.value only when you need it
Controlled is a circle: type → handler → state → render → input. Uncontrolled is a dead end: the value changes in the DOM and nothing else happens until you read it.
How It Works
The two sides of the same input.
function ControlledName() {
const [name, setName] = useState('');
return (
<input value={name} onChange={(e) => setName(e.target.value)} />
);
}
The value prop makes React the owner. When the browser edits the DOM, React immediately
rewrites it from state on the next render — so the input can't hold a value state doesn't know
about. onChange fires on every keystroke (it is React's synthetic input event, Lesson 53),
and that is the only way the value moves.
Uncontrolled, same field:
function UncontrolledName() {
const ref = useRef(null);
return <input ref={ref} defaultValue="guest" />;
}
defaultValue seeds the field once, on mount. After that the input owns its value and React
stays out of the way. Reading it later is ref.current.value.
Note
defaultValue (not value) is the uncontrolled prop. Pass value without onChange and
React treats it as controlled-but-readonly — typing does nothing. That "frozen input" is the
most common controlled/uncontrolled bug in the wild, and we come back to it in Section 8.
Real Project Usage
Validation is the controlled killer app, because a controlled input gives you a live value with zero extra reads — you derive everything else from the same state that's already rendering.
| Feature | Controlled | Uncontrolled |
|---|---|---|
| Read the value | it's state, already in scope | ref.current.value |
| Validate per keystroke | derive from the same state | listen, store, re-read — now you're controlled |
| Sync two fields | both read the same state | keep refs in sync by hand |
| Disable submit | disabled={!valid} | read every ref on submit |
| Reset a form | set the state to '' | call form.reset() or re-mount |
| File inputs | cannot be controlled | the only value you never read |
The last row is the honest carve-out: <input type="file">'s value is set by the OS and read-only
to React, so it's uncontrolled whether you like it or not. You pass a ref and read
files in your submit handler.
Interview Explanation
"A controlled input stores its value in React state and updates it through
onChange; the input renders whatever state holds. An uncontrolled input keeps its value in the DOM, seeded once withdefaultValue, and you read it through a ref. I default to controlled because validation and cross-field logic are just derived from state — uncontrolled stays for file inputs and the rare third-party widget that manages its own DOM."
Senior-Level Insights
The question is never which one is better — it's who owns this value, and does anyone else need it?
- Ownership rule of thumb. A field nobody else reads is a candidate for uncontrolled. The moment validation, a submit payload, or a sibling needs the value, it must be owned somewhere React can see it — usually that means controlled.
- Don't skip the form. Form libraries (
react-hook-form,Formik) are not a third category: they are opinionated wrappers that make controlled values cheap. Knowing that reframes "which library" as "which ownership model". - Know the escape hatches. If uncontrolled,
form.reset()and re-mounting (akeychange) are the two sanctioned resets; if controlled, reset issetState(''). - Rendering cost is rarely the argument. Section 14 says the size of the win: for most forms it's nil. Say "I could use uncontrolled here and re-render on submit — but then validation runs a render late and every feature reads through refs".
Common Mistakes
The frozen input — value without onChange:
<input value={name} /> // ❌ types once, then the DOM edit is overwritten — input is dead
The fix is the other half of the pair:
<input value={name} onChange={(e) => setName(e.target.value)} />
Mistaking defaultValue for a fallback. It seeds the uncontrolled input once, on mount. If
state later changes, defaultValue doesn't follow it — the DOM value stays:
<input defaultValue={savedName} /> // ❌ savedName changes → input keeps its old text
That's the same trap from the other side: two sources of truth, one of them stale. The cheat-sheet rule handles every case — read the value from state (controlled) or the DOM (uncontrolled), never both.
Best Practices
✅ Default to controlled — value={x} onChange={...} — for anything the app needs to read
✅ Derive validation from the same state: const valid = email.trim().length > 0
✅ Use defaultValue + a ref when the value is only needed on submit
✅ Leave file inputs uncontrolled and read ref.current.files
✅ Disable the submit button off derived state, not by re-reading inputs
❌ Don't pass value without onChange — that's a frozen input, not an optimization
❌ Don't keep a duplicate useState mirror of an input you're controlling; there's already one source of truth
Interview Questions
Q1. What is the difference between a controlled and an uncontrolled component?
A controlled component gets its value from props — in a form,
valueplusonChangewired to state — so React renders exactly what state holds. An uncontrolled component keeps its value in the DOM, seeded withdefaultValue, and I read it through a ref.The tell: does typing go through a re-render? Controlled, yes. Uncontrolled, no.
Q2. Why would you prefer a controlled form?
Because the value is already in React state, validation and cross-field rules become derived state (Lesson 55) — compute them in render instead of syncing anything. That removes a whole class of "form says one thing, state says another" bugs.
Q3. When would you use an uncontrolled form?
When React doesn't need the value until submit — a search box that only navigates — and always for file inputs, whose value React can't own. Also, occasionally, for a third-party component that manages its own DOM and would fight a controlled value.
Q4. What does defaultValue do?
It seeds an uncontrolled input's DOM value once, on mount. It is not a dynamic value: it doesn't update when the prop changes, and React doesn't read the input afterward. That's why the controlled prop is
value, and the uncontrolled one isdefaultValue.
Senior follow-up: What happens if you pass value but no onChange?
React treats the input as controlled with no way to update: the first keystroke edits the DOM, the next render writes the prop back, and the input appears frozen. It's the classic bug, and the fix is to add the handler — or switch to
defaultValueif you genuinely wanted uncontrolled.
Follow-Up Questions
Q. How do you reset a controlled form?
By resetting the state — every field renders from state, so one
setStatepass clears the whole form. That's the payoff of a single source of truth.
Q. Why can't you control a file input?
Because the browser sets its value from the file picker and forbids scripts from writing it. React can't render a value into it, so there's nothing to control — you read
ref.current.filesinstead.
Q. How do you read an uncontrolled value in a submit handler?
From the ref:
const name = nameRef.current.value. If you find yourself storing that in state to share it, you've just rebuilt a controlled input — stop and make it controlled.
Comparison Table
| Controlled | Uncontrolled | |
|---|---|---|
| Value source | React state (props) | the DOM node |
| Read it | already a variable | ref.current.value |
| Write it | setState → re-render | ref.current.value = x (manual) |
| Default value | value initialised in state | defaultValue |
| Re-render per keystroke | yes | no |
| Validation | derived from state | needs an event + stored state anyway |
| Reset | one setState | form.reset() / re-mount |
| File inputs | impossible | required |
| Use when | the app needs the value | only you, at submit, does |
Code Example
A small signup form, controlled throughout, with live validation:
import { useState } from 'react';
export default function Signup() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const valid = email.includes('@') && password.length >= 6;
function submit(e) {
e.preventDefault(); // Lesson 53 — don't reload the page
console.log('submitting', { email, password });
}
return (
<form onSubmit={submit}>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button disabled={!valid}>Sign up</button>
</form>
);
}
typing "a@b" + "secret1" → valid: false … false … true → button enabled
submit → console: submitting { email: 'a@b', password: 'secret1' }
No refs, no listeners, no syncing. valid is derived from the same state that renders the
inputs (the exact pattern Lesson 55 formalises), and disabled falls out of it.
Performance Notes
Controlled forms re-render the form on every keystroke. The honest number: for a handful of inputs that cost is noise — React's reconciliation (Lesson 51) is the thing that keeps it that way. It only starts mattering with dozens of controlled fields per screen, and even then the fix is usually moving the field into its own component so the keystroke re-render is isolated, not switching to uncontrolled.
Uncontrolled skips those re-renders entirely, which is why input-heavy third-party widgets stay uncontrolled. But "fewer re-renders" is not a reason to abandon controlled: measure first, scope the re-render second. Validation that runs a render late — because the value only reaches state on submit — is a correctness cost, not a performance win.
Debugging Scenarios
"I can type once, then the input is dead." value without onChange. React re-renders
after the edit and overwrites the DOM with the old prop. Fix: add the handler, or switch to
defaultValue if you meant uncontrolled.
"The field shows the saved value on load, but stops following it." defaultValue + state
you expected to sync. defaultValue seeds once and never re-applies. If the value must track
props, control it: value={savedName} onChange={...}.
"Two fields that should match don't." Each field owns its own state. Derive one from the other, or lift the shared value (Lesson 55).
"Submitting shows stale values." You're reading refs you set on an earlier render, or the
submit handler closed over old state (Lesson 5). Read ref.current at submit time, or keep
the value in state so the handler always sees the current render's value.
Quick Revision Notes
- Controlled =
value+onChange; the input renders what state holds - Uncontrolled =
defaultValue+ ref; the DOM owns the value - One source of truth per field — state or DOM, never both
- Validation, disable, and cross-field logic are derived state — controlled gives you that free
- File inputs are always uncontrolled
- Frozen input =
valuewith noonChange; stale field =defaultValueyou expected to follow props - Per-keystroke re-renders are fine until you've measured otherwise
Cheat Sheet
// controlled — value + onChange, state is the source of truth
const [email, setEmail] = useState('');
<input value={email} onChange={(e) => setEmail(e.target.value)} />
// uncontrolled — defaultValue seeds once, ref reads later
const emailRef = useRef(null);
<input ref={emailRef} defaultValue="a@b.com" />
const email = emailRef.current.value; // at submit time
// derive, don't store (Lesson 55)
const valid = email.includes('@'); // computed in render, not synced
// reset
setEmail(''); // controlled
formRef.current.reset(); // uncontrolled (or re-mount with a new key)
// file input — always uncontrolled
const fileRef = useRef(null);
<input type="file" ref={fileRef} />
const [file] = fileRef.current.files;Key Takeaways
Recap
- Controlled:
value+onChange— React state is the source of truth, typing re-renders - Uncontrolled:
defaultValue+ ref — the DOM owns the value, React reads it on demand - One boss per value; the frozen input is
valuewith noonChange - Validation is derived state, which is exactly what controlled gives you
- File inputs are always uncontrolled
- Per-keystroke re-renders are noise until measured
Check your understanding
Answer each one out loud. Then rate yourself honestly.
0/6In one sentence, what makes an input controlled?
What prop seeds an uncontrolled input, and why does it not update later?
Walk through what happens in the DOM and in React when a user types one character into a controlled input.
Give two concrete things controlled gives you that uncontrolled doesn't.
Why can a file input never be controlled?
Your colleague's input freezes after the first keystroke. Diagnose it in one sentence.
What's Next
Lesson 55 — Derived State & Lifting State. Where valid above came from, and the rule
that "state that can be computed from other state isn't state at all".