Forms: Controlled vs Uncontrolled
Two Philosophies of Form State
React gives you two fundamentally different approaches to forms:
- Controlled: React state is the single source of truth. The input value is driven by state, and every change flows through an event handler.
- Uncontrolled: The DOM is the source of truth. You read values with refs when you need them (typically on submit).
// Controlled — React owns the value
function ControlledInput() {
const [value, setValue] = useState('');
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
// Uncontrolled — DOM owns the value
function UncontrolledInput() {
const inputRef = useRef(null);
function handleSubmit() {
console.log(inputRef.current.value); // Read from DOM
}
return <input ref={inputRef} defaultValue="" />;
}
Think of controlled inputs as puppet mode — React pulls every string, and the input can only show what React tells it to show. Think of uncontrolled inputs as freeform mode — the input manages itself, and React peeks at the result when needed. Puppet mode gives total control at the cost of more code. Freeform mode is simpler but React cannot validate or transform input in real time.
Controlled Components In Depth
In a controlled component, the React state is the single source of truth:
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
function handleSubmit(e) {
e.preventDefault();
login(email, password);
}
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<button type="submit">Log In</button>
</form>
);
}
Why the Input Freezes Without onChange
If you set value without an onChange, the input becomes read-only:
// This input cannot be typed into:
<input value="frozen" />
// React overwrites the DOM value on every render.
// User types → DOM updates → React re-renders → value is set back to "frozen".
React forces the DOM input value to match the state value after every render. Without an onChange handler to update state, the state never changes, and the input appears frozen.
Setting value={undefined} makes a controlled input behave as uncontrolled (no value control). This can happen accidentally when state is undefined on mount. If you see an input that starts editable but becomes frozen, check if the initial state transitions from undefined to a string — React switches from uncontrolled to controlled mode and logs a warning.
Real-Time Validation
Controlled inputs enable validation on every keystroke:
function CreditCardInput() {
const [card, setCard] = useState('');
const [error, setError] = useState('');
function handleChange(e) {
const raw = e.target.value.replace(/\D/g, ''); // Strip non-digits
const formatted = raw.replace(/(\d{4})/g, '$1 ').trim(); // Add spaces
setCard(formatted);
if (raw.length > 0 && raw.length < 16) {
setError('Card number must be 16 digits');
} else {
setError('');
}
}
return (
<div>
<input value={card} onChange={handleChange} maxLength={19} />
{error && <span className="error">{error}</span>}
</div>
);
}
This transforms user input in real time — impossible with uncontrolled inputs.
Uncontrolled Components
Uncontrolled components use defaultValue (not value) and read the DOM when needed:
function FileUploadForm() {
const fileRef = useRef(null);
const nameRef = useRef(null);
function handleSubmit(e) {
e.preventDefault();
const formData = new FormData();
formData.append('name', nameRef.current.value);
formData.append('file', fileRef.current.files[0]);
upload(formData);
}
return (
<form onSubmit={handleSubmit}>
<input ref={nameRef} defaultValue="" placeholder="File name" />
<input ref={fileRef} type="file" />
<button type="submit">Upload</button>
</form>
);
}
File inputs (<input type="file" />) are always uncontrolled in React. You cannot set their value programmatically due to browser security restrictions. Use refs to read the selected files.
The FormData API Approach
Modern React patterns increasingly use the native FormData API, which works well with both controlled and uncontrolled inputs:
function ContactForm() {
function handleSubmit(e) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = Object.fromEntries(formData);
// data = { name: 'Alice', email: 'alice@example.com', message: '...' }
submitForm(data);
}
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
);
}
React 19 Server Actions and forms
React 19 introduces the action prop on forms, which works with Server Actions in Next.js:
async function createUser(formData) {
'use server';
const name = formData.get('name');
// Server-side processing
}
function SignupForm() {
return (
<form action={createUser}>
<input name="name" required />
<button type="submit">Sign Up</button>
</form>
);
}The action prop accepts a function that receives FormData. Combined with useActionState, this provides built-in pending states, error handling, and optimistic updates. This pattern favors uncontrolled inputs with name attributes over controlled state.
When to Use Each
| Feature | Controlled | Uncontrolled |
|---|---|---|
| Real-time validation | Yes | No |
| Input formatting/masking | Yes | No |
| Conditional field disabling | Easy | Harder |
| Performance (many fields) | Re-render per keystroke | No re-renders |
| Form library integration | Works with all | Works with some |
| File inputs | Cannot | Must use |
| Submit-only validation | Overkill | Ideal |
| Testing | State-based assertions | DOM queries |
Production Scenario: Dynamic Form with Validation
function RegistrationForm() {
const [form, setForm] = useState({
username: '',
email: '',
password: '',
});
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
function updateField(field, value) {
setForm(prev => ({ ...prev, [field]: value }));
if (touched[field]) {
validateField(field, value);
}
}
function handleBlur(field) {
setTouched(prev => ({ ...prev, [field]: true }));
validateField(field, form[field]);
}
function validateField(field, value) {
const fieldErrors = { ...errors };
switch (field) {
case 'username':
fieldErrors.username = value.length < 3 ? 'At least 3 characters' : '';
break;
case 'email':
fieldErrors.email = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? '' : 'Invalid email';
break;
case 'password':
fieldErrors.password = value.length < 8 ? 'At least 8 characters' : '';
break;
}
setErrors(fieldErrors);
}
return (
<form>
{['username', 'email', 'password'].map(field => (
<div key={field}>
<input
type={field === 'password' ? 'password' : 'text'}
value={form[field]}
onChange={(e) => updateField(field, e.target.value)}
onBlur={() => handleBlur(field)}
/>
{touched[field] && errors[field] && (
<span className="error">{errors[field]}</span>
)}
</div>
))}
</form>
);
}
-
Wrong: Setting value without onChange — creating a read-only input Right: Always pair value with onChange, or use defaultValue for uncontrolled
-
Wrong: Switching between controlled and uncontrolled (value goes from undefined to string) Right: Initialize state to an empty string, not undefined
-
Wrong: Using state for every input in a large form (dozens of fields) Right: Consider uncontrolled with FormData, or a form library like React Hook Form
-
Wrong: Trying to set value on file inputs Right: File inputs are always uncontrolled. Use refs to read files.
- 1Controlled: React state is truth. value + onChange always paired. Re-renders on every keystroke.
- 2Uncontrolled: DOM is truth. defaultValue + ref. Read on submit. No re-renders during typing.
- 3File inputs are always uncontrolled — browser security prevents setting their value.
- 4Never switch between controlled and uncontrolled — initialize state to '' not undefined.
- 5For large forms, consider uncontrolled with FormData or a library like React Hook Form.