The Controlled vs. Uncontrolled Split That Breaks React Forms Silently
React Forms' One Pitfall: Mixing Controlled and Uncontrolled — Silent Failure Is Scarier Than Errors
Introduction
Look at a login form. Can you spot the difference between the two state updates below?
const handleChange = (e) => {
const { name, value } = e.target;
// This one spreads directly
setForm({
...form,
[name]: value
});
// This one uses prev =>
setErrors(prev => ({
...prev,
[name]: msg
}));
};
validate(name, value);
Both look fine? Both can run?
The problem hides here: If you quickly type "admin", the form that setForm({...form}) gets might not be the form you think it is. But the prev that setErrors(prev => ...) gets is definitely the latest prev.
Inside the same function, two adjacent lines of code, two different writing styles. It's not a style issue — it's that your understanding of React's state update model has split within the same function.
This article is here to solve that problem.
You Need to Understand One Thing First: Does React "Manage" the DOM or Not?
Before talking about forms, look at two of the simplest input boxes. They look identical — a single <input> — but behind them are two completely different worldviews.
Controlled Component (React manages the DOM):
function ControlledInput() {
const [value, setValue] = useState('');
return (
<input
type="text"
value={value} // 🔑 React controls the display
onChange={e => setValue(e.target.value)} // 🔑 React intercepts every keystroke
/>
);
}
Uncontrolled Component (DOM manages itself):
function UncontrolledInput() {
const inputRef = useRef(null);
const handleClick = () => {
console.log(inputRef.current.value); // 🔑 Reads from the DOM only when needed
};
return (
<input type="text" ref={inputRef} /> // ⚠️ No value, no onChange
);
}
Definition in one sentence: Controlled = React state is the single source of truth. Uncontrolled = DOM is the single source of truth, and React only reads it occasionally.
That Diagram, Seeing the Difference at a Glance
sequenceDiagram
participant User as User
participant DOM as DOM (input)
participant React as React State
User->>DOM: Types "a"
DOM->>React: onChange → setValue("a")
React->>DOM: re-render → value="a"
User->>DOM: Types "b"
DOM->>React: onChange → setValue("ab")
React->>DOM: re-render → value="ab"
Note over React,DOM: Controlled: Every keystroke is a React → DOM round trip
sequenceDiagram
participant User as User
participant DOM as DOM (input)
participant React as React Ref
User->>DOM: Types "a"
User->>DOM: Types "b"
User->>DOM: Types "c"
User->>DOM: Clicks "Submit"
DOM->>React: ref.current.value → "abc"
Note over React,DOM: Uncontrolled: React ignores what you type in the DOM until you read it yourself
Controlled is one round trip per keystroke; uncontrolled is "I ignore you until I need you."
Real Scenario: A Registration Form — When Does It Go Wrong?
Back to our registration form:
const [form, setForm] = useState({
username: '',
password: ''
});
const handleChange = (e) => {
setForm({
...form, // ⚠️ This form… is it the one right now?
[e.target.name]: e.target.value
});
};
Typing the five characters of user — you wrote it 5 times. Normally it's OK, because React batches updates: each keystroke triggers one render, and the next handleChange gets the new form.
But what if a future version of React changes the batching strategy? What if a certain render gets suspended?
The truth is: In React 18's concurrent mode,
...formspreads a closure snapshot, which is not necessarily the latest state at this moment. The functional updateprev => ({...prev})is what React promises as "always the latest."
You might ask — then why was the other update in my LoginForm correct?
// ✅ This uses a functional update — always safe
setErrors(prev => ({
...prev,
[name]: msg
}));
// ⚠️ This uses direct spread — depends on a closure snapshot
setForm({
...form,
[name]: value
});
Inside the same handleChange, one is safe, one is unsafe. This is not a style choice — it's an unconscious bug caused by your inconsistent understanding of this concept.
Let's write an experiment to prove it:
// Simulating two rapid consecutive calls to handleChange (an extreme case under React 18 concurrent mode)
function TestForm() {
const [form, setForm] = useState({ name: '' });
const simulateRapidTyping = () => {
// Two consecutive setStates, the second might read the form from before the first
setForm({ ...form, name: 'a' }); // Reads form = { name: '' }
setForm({ ...form, name: 'b' }); // Still reads form = { name: '' }!
// Result: Only 'b' is kept? No — the last setState wins
// But what if a validate judges based on form.name…
};
const fixedVersion = () => {
setForm(prev => ({ ...prev, name: 'a' })); // Reads prev = { name: '' }
setForm(prev => ({ ...prev, name: 'b' })); // Reads prev = { name: 'a' }!
// Result: name = 'b', and the intermediate prev chain is complete
};
}
Real execution result:
Direct spread: Both setForms read form.name = '', the last one overwrites the previous
Functional update: The second reads prev.name = 'a', the update chain is complete
The difference lies in this one prev =>. It's not syntactic sugar — it's the hedge between the "determinism of state updates" and the "uncertainty of closures" in React's design.
So How Do You Choose Between Controlled and Uncontrolled?
Back to our earliest comparison — both ControlledInput and UncontrolledInput exist legitimately:
| Choose Controlled | Choose Uncontrolled |
|---|---|
| Need real-time validation (feedback on input) | Only grab value on submit |
| Input value affects UI (search suggestions, character count) | Multiple input fields, read all at once on submit |
| Need to format input (auto-add spaces to phone numbers) | Performance-sensitive forms (many fields, frequent renders) |
| Conditional button disable depends on input value | file input (natively uncontrolled, React has no value attribute) |
The criterion is not "use useState or useRef" — the criterion is "Does React need to know this value on every keystroke?"
All Problems of the LoginForm, Seen Through at Once
Looking back at the complete flow of our login form:
graph TD
A["User types username"] --> B["handleChange"]
B --> C["setForm({...form})"]
B --> D["validate(name, value)"]
D --> E["setErrors(prev => ({...prev}))"]
C --> F["form updates"]
E --> G["errors updates"]
F --> H["isValid recalculates"]
G --> H
H --> I["isValid judges"]
I -->|true| J["Button clickable"]
I -->|false| K["Button disabled"]
Key points:
- validate is reading the value parameter, not reading form state — so even if
setFormuses the risky direct spread, validate is unaffected - isValid depends on both form and errors states — if form's update is overwritten or delayed, isValid's judgment is wrong
- The update patterns for form and errors are inconsistent — this is not a code style issue, it's an invisible logic breakpoint
What makes this bug hardest to debug? It doesn't throw an error. It just occasionally "the button state is wrong" — you change the input order, add a console.log, and it's fine again. This kind of silent failure is scarier than an error.
Back to Design Philosophy: Why Does React Give You the Choice?
Now you should be asking: Why doesn't React just support one mode? Why force the controlled/uncontrolled dilemma on people?
Because React's core philosophy is "Declarative UI = a pure function of state → view." But for form inputs, the DOM itself is a state container — <input> remembers what you typed on its own. React faces a choice:
Sync the DOM's state to React state (controlled), or trust the DOM to manage its own state (uncontrolled)?
React's answer: I give you two choices, but you must explicitly pick one. Mixing them (neither providing value nor providing ref)? React warns you directly in the console.
This is React's philosophy — it doesn't make the decision for you, but it forces you to make a decision. Implicit dependencies are the source of bugs; explicit declarations are the foundation of stability.
Conclusion
Back to the opening question — why must setForm({...form}) and setErrors(prev => ({...prev})) in the LoginForm be consistent?
Remember: Choosing controlled or uncontrolled is essentially choosing "where to put the single source of truth for the data." If you put it in React state, you must use functional updates to protect this truth from being washed away by concurrency. If you put it in the DOM, don't pretend you know its value in real time.
React gives you not two APIs, but two promises. Choose controlled, and you promise that every keystroke passes through you. Choose uncontrolled, and you promise to read only when truly needed — not pretending to know in between.
Next time you write a form, the first thing is not writing useState or useRef. It's asking yourself: "For this input's value, does React need to know it on every keystroke?"
Have you ever had the experience in your project forms of "clearly called setState but the value read was wrong"? Let's chat in the comments.