The core question: who owns the value?
Every form element in React has one fundamental question: who is the source of truth for its current value — React state or the DOM?
- Controlled component → React state is the source of truth. The
valueprop drives the DOM. Every keystroke goes through state. - Uncontrolled component → The DOM is the source of truth. React sets the initial value and steps back. You read the value only when you need it.
Everything else follows from this distinction.
Controlled components in depth
A controlled input is one where value is driven by React state and every
change is funnelled through onChange:
function EmailField() {
const [email, setEmail] = useState('')
return (
<input
type="email"
value={email} // state → DOM
onChange={e => setEmail(e.target.value)} // DOM event → state
/>
)
}
This creates a tight feedback loop. React knows the field's value at all times and can:
- Validate on every keystroke and show/hide error messages
- Disable a submit button until the form is complete
- Transform input (uppercase, format phone numbers, trim whitespace)
- Reset the form by simply calling
setState(initialValues) - Read the complete form data in the submit handler without touching the DOM
The cost is that you must wire value + onChange for every field. For large
forms this is repetitive — which is why libraries like React Hook Form exist.
The read-only trap
If you pass value without onChange, React freezes the input — users type
but the DOM value is reset to value on every render. React warns:
"You provided a
valueprop to a form field without anonChangehandler. This will render a read-only field."
Fix it with onChange, or use readOnly if the intent is truly read-only:
// ❌ Frozen — user can't type
<input value={someValue} />
// ✅ Controlled
<input value={someValue} onChange={e => setValue(e.target.value)} />
// ✅ Intentionally read-only
<input value={someValue} readOnly />
Starting controlled with an empty string
Never initialise controlled state with null or undefined. That starts the
input as uncontrolled (React treats a null value as "no value"), and when
you later assign a real string React will warn about switching from
uncontrolled to controlled.
// ❌ Starts uncontrolled → triggers warning when set to a real string
const [text, setText] = useState(null)
// ✅ Always start with a string (even empty)
const [text, setText] = useState('')
Uncontrolled components in depth
An uncontrolled input lets the browser manage its own value. You attach a
ref to read the value imperatively when needed:
function SearchBox() {
const inputRef = useRef(null)
function handleSubmit(e) {
e.preventDefault()
performSearch(inputRef.current.value) // read on demand
}
return (
<form onSubmit={handleSubmit}>
<input ref={inputRef} defaultValue="" />
<button type="submit">Search</button>
</form>
)
}
defaultValue sets the initial value without making the input controlled.
After that, the DOM owns the value.
When uncontrolled is appropriate
- Submit-only forms where you need the value once, not on every keystroke
- File inputs —
<input type="file">is always uncontrolled. The browser controls the file path for security; you can never setvalueon it - Integrating third-party DOM libraries that manipulate inputs directly (rich text editors, date pickers that aren't React-aware)
// File input — must use ref, value prop is not supported
function FileUpload() {
const fileRef = useRef(null)
function upload() {
const file = fileRef.current.files[0]
if (file) formData.append('file', file)
}
return <input type="file" ref={fileRef} />
}
value vs defaultValue — the complete picture
| Prop | Effect |
|---|---|
value | Input is controlled — React drives the DOM value on every render |
defaultValue | Input is uncontrolled — sets the initial DOM value, then DOM takes over |
| Neither | Input is uncontrolled with no initial value |
The same pattern applies to <textarea> and <select>:
// Controlled textarea
<textarea value={text} onChange={e => setText(e.target.value)} />
// Controlled select — value matches an <option>'s value
<select value={chosen} onChange={e => setChosen(e.target.value)}>
<option value="a">A</option>
<option value="b">B</option>
</select>
// Uncontrolled select with default
<select defaultValue="b" ref={selectRef}>
<option value="a">A</option>
<option value="b">B</option>
</select>
Programmatic reset
Controlled forms reset with a single state update:
const EMPTY = { name: '', email: '' }
const [form, setForm] = useState(EMPTY)
function handleReset() { setForm(EMPTY) } // ← all inputs snap to empty
Uncontrolled forms require manually setting each ref:
function handleReset() {
nameRef.current.value = ''
emailRef.current.value = ''
// repeat for every field
}
Controlled wins for reset, prefill, and programmatic form manipulation.
Dynamic field lists
Controlled is especially powerful for dynamic forms:
function TagsInput() {
const [tags, setTags] = useState(['react'])
return (
<>
{tags.map((tag, i) => (
<input
key={i}
value={tag}
onChange={e => setTags(prev =>
prev.map((t, idx) => idx === i ? e.target.value : t)
)}
/>
))}
<button onClick={() => setTags(t => [...t, ''])}>+ Add</button>
</>
)
}
With uncontrolled inputs you'd need to track refs in an array and manually read each one — significantly more code for the same behaviour.
How React Hook Form bridges the gap
React Hook Form uses uncontrolled inputs internally but gives you a controlled-like developer experience. It subscribes to DOM change events directly without storing every keystroke in React state, which means far fewer re-renders:
import { useForm } from 'react-hook-form'
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors }
} = useForm()
return (
<form onSubmit={handleSubmit(data => api.signup(data))}>
<input
{...register('email', {
required: 'Email is required',
pattern: { value: /@/, message: 'Invalid email' }
})}
/>
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">Sign up</button>
</form>
)
}
register wires up a ref and an onChange handler internally — the
component re-renders only when validation state changes or on submit, not on
every keystroke.
For controlled integration (when the input value must flow back to React
state), use Controller or useController.
Quick decision guide
| Need | Prefer |
|---|---|
| Live validation | Controlled |
| Submit-only, simple form | Uncontrolled |
| File upload | Uncontrolled (forced) |
| Programmatic reset/prefill | Controlled |
| Large complex form with performance concerns | React Hook Form (uncontrolled internally) |
| Integration with non-React DOM library | Uncontrolled |
Key interview points
- Controlled: React state owns the value via
value+onChange. Full power, some boilerplate. - Uncontrolled: DOM owns the value after mount. Read it with a
ref. Simpler, less control. value→ controlled.defaultValue→ uncontrolled seed.- Never pass
null/undefinedas thevalueprop — use''instead. <input type="file">is always uncontrolled.- React Hook Form uses uncontrolled internals for performance but controlled ergonomics in the API.