Controlled and uncontrolled components in React
Controlled component stores form value in React state and syncs it on every keystroke. Uncontrolled component lets the DOM hold the value and you read it via ref only when needed.
Theory
TL;DR
- Controlled: React state is the source of truth,
onChangefires on every keystroke and triggers a re-render - Uncontrolled: DOM holds the value, you read it via
refon demand - Analogy: controlled is a live chat that syncs every character to the server; uncontrolled is a paper form you fill out and hand in at the end
- Use controlled for real-time validation or conditional rendering
- Use uncontrolled for simple forms, file inputs, or third-party library integration
Quick example
// CONTROLLED: React owns the value
function Controlled() {
const [name, setName] = useState("");
return (
<input value={name} onChange={(e) => setName(e.target.value)} />
// name is always in sync with what the user typed
);
}
// UNCONTROLLED: DOM owns the value
function Uncontrolled() {
const inputRef = useRef();
return (
<>
<input ref={inputRef} />
<button onClick={() => console.log(inputRef.current.value)}>
Submit
</button>
{/* value is read only on button click */}
</>
);
}In controlled, value and onChange are always paired. In uncontrolled, the ref points directly to the DOM node.
Key difference
In a controlled component, React state is the single source of truth. Every keystroke fires onChange, updates state, and triggers a re-render with the new value prop. In an uncontrolled component, the browser updates the DOM directly without notifying React. When you read inputRef.current.value, you bypass React entirely and read from the DOM node. This means a controlled input is always in sync with React state, while an uncontrolled input can hold a value React knows nothing about.
When to use
Controlled:
- Real-time validation - disable the submit button while an email field is invalid
- Conditional rendering based on input - show autocomplete suggestions as the user types
- Pre-filling fields with data from an API or props
- Any situation where React needs to respond to every change
Uncontrolled:
- Simple forms where you only need the value on submit
- File inputs (
<input type="file">) - browsers block settingvalueprogrammatically for security reasons - Integrating third-party DOM libraries that manage their own state
- Performance-sensitive forms with many fields where re-rendering on every keystroke causes visible lag
Comparison table
| Aspect | Controlled | Uncontrolled |
|---|---|---|
| State location | React state | DOM element |
| Sync timing | Every keystroke | On demand (via ref) |
| Re-render | Yes, on every change | No, unless you trigger it manually |
| Validation | Real-time possible | Only on submit |
| Initial value prop | value | defaultValue |
| When to use | Validation, conditional UI, pre-fill | Simple forms, file inputs, third-party libs |
How it works internally
When you type in a controlled input, the browser fires an onChange event. React's handler calls useState setter, schedules a re-render, diffs the virtual DOM, and patches the actual DOM with the new value prop. Everything happens in the same event cycle.
For uncontrolled inputs, the browser updates the DOM directly. React's virtual DOM never sees the change. Reading inputRef.current.value goes straight to the DOM node, skipping React completely. No re-render, no state update, no diffing.
Common mistakes
1. Using value without onChange
// WRONG: input becomes read-only
<input value={name} />
// RIGHT: always pair them
<input value={name} onChange={(e) => setName(e.target.value)} />React sets the value prop but has no way to update it. The user cannot type anything. React also logs a warning in the console about this exact issue.
2. Using value instead of defaultValue in uncontrolled components
// WRONG: this creates a controlled input with no onChange handler
<input ref={inputRef} value="initial" />
// RIGHT: defaultValue sets the initial value without taking control
<input ref={inputRef} defaultValue="initial" />3. Reading a ref before the component mounts
// WRONG: inputRef.current is null during the first render
function Form() {
const inputRef = useRef();
const value = inputRef.current.value; // TypeError: null
return <input ref={inputRef} />;
}
// RIGHT: read refs in event handlers or effects
function Form() {
const inputRef = useRef();
const handleClick = () => {
console.log(inputRef.current.value); // safe here
};
return <input ref={inputRef} />;
}4. Initializing controlled state as undefined
// WRONG: undefined means no value prop, so React treats input as uncontrolled
const [name, setName] = useState();
<input value={name} onChange={(e) => setName(e.target.value)} />
// RIGHT: always initialize with an empty string
const [name, setName] = useState("");
<input value={name} onChange={(e) => setName(e.target.value)} />React will warn: "A component is changing an uncontrolled input to be controlled." This happens because undefined equals no value prop on the first render.
5. Running expensive operations on every keystroke
// SLOW: expensiveSearch fires on every character typed
const handleChange = (e) => {
setSearch(e.target.value);
expensiveSearch(e.target.value);
};
// BETTER: debounce the expensive part, update state immediately
const handleSearch = useCallback(
debounce((value) => expensiveSearch(value), 300),
[]
);
const handleChange = (e) => {
setSearch(e.target.value);
handleSearch(e.target.value);
};Real-world usage
- React Hook Form defaults to uncontrolled inputs for performance - it stores values in a ref, not state, which avoids re-rendering the form on every keystroke
- Material-UI and Chakra UI pass
valueandonChangeto all form components - controlled pattern by default <input type="file">is always uncontrolled because browsers block programmaticvaluesetting for security- Redux forms store field values in Redux state - the controlled pattern at a global level
- Next.js Server Actions pair naturally with uncontrolled forms because you can pass a
FormDataobject directly to the action without any state involved
Follow-up questions
Q: Why does React warn "You provided a value prop without an onChange handler"?
A: Because the input becomes read-only. React sets the value but has no mechanism to update it, so users cannot type. React detects the mismatch early so you don't waste time debugging a frozen input field.
Q: Can a component switch from uncontrolled to controlled during its lifetime?
A: No. React throws an error if you change a component from uncontrolled to controlled or vice versa. Pick one pattern and keep it for the component's entire lifetime.
Q: What is the actual performance difference?
A: Controlled components re-render on every keystroke. With large forms or expensive render logic this causes visible lag. Uncontrolled components skip React's render cycle entirely, but you lose real-time validation. React Hook Form solves this by using uncontrolled inputs internally while exposing a controlled-like API.
Q: A form with 50 fields - controlled or uncontrolled?
A: Controlled with optimization. Split the form into smaller sub-components, memoize handlers with useCallback, and debounce any expensive operations. Or use React Hook Form, which handles this internally and gives you both performance and real-time validation.
Q: (Senior) Why does React Hook Form default to uncontrolled components, and when would you override that?
A: React Hook Form stores values in a ref instead of state to avoid re-rendering the entire form on every keystroke. You switch to controlled mode (via the Controller component or useController hook) when integrating with controlled UI libraries like Material-UI, or when you need conditional rendering that reacts to live input values.
Examples
Controlled form with live email validation
function SignupForm() {
const [email, setEmail] = useState("");
const [error, setError] = useState("");
const handleChange = (e) => {
const value = e.target.value;
setEmail(value);
if (value && !value.includes("@")) {
setError("Invalid email");
} else {
setError("");
}
};
return (
<div>
<input value={email} onChange={handleChange} placeholder="Email" />
{error && <span style={{ color: "red" }}>{error}</span>}
{/* error appears and disappears as the user types */}
</div>
);
}The error message reacts in real time because controlled inputs re-render on every keystroke. With an uncontrolled input you'd only catch the invalid email after the user hits submit.
Uncontrolled form for simple submission
function ContactForm() {
const nameRef = useRef();
const emailRef = useRef();
const handleSubmit = (e) => {
e.preventDefault();
const data = {
name: nameRef.current.value,
email: emailRef.current.value,
};
console.log(data); // both values read at once on submit
};
return (
<form onSubmit={handleSubmit}>
<input ref={nameRef} defaultValue="" placeholder="Name" />
<input ref={emailRef} defaultValue="" placeholder="Email" />
<button type="submit">Send</button>
</form>
);
}No re-renders happen as the user types. React gets involved only at submit. This works well for simple contact forms where you don't need any live feedback.
Why mixing both patterns causes confusion
// DON'T DO THIS
function BadMix() {
const [value, setValue] = useState("");
const inputRef = useRef();
return (
<>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
ref={inputRef}
/>
<button onClick={() => console.log(inputRef.current.value)}>
Log value
</button>
{/* ref reads the DOM node correctly here, but so does value state */}
{/* two ways to get the same data - pick one */}
</>
);
}The ref reads the DOM node correctly here. But the state already holds the same value. Using both creates unnecessary confusion about which one to trust. I have seen this pattern in code reviews often enough - it usually comes from developers who learned jQuery first and try to keep a foot in both worlds. Pick one approach and stay consistent.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.