Controlled vs uncontrolled inputs
One of the first decisions you'll make when building a React form is whether to let React manage the input value or let the DOM handle it. This choice—between controlled and uncontrolled inputs—affects everything from performance to how your UI responds to user interaction. I've seen teams pick the wrong approach and regret it later when they needed to validate on every keystroke or optimize a form with fifty fields.
What are controlled inputs?
In a controlled input, React state owns the value. Every keystroke, every change, flows through your component's state. The input's value prop is explicitly set from state, and an onChange handler updates that state when the user types.
Here's the pattern:
import { useState } from 'react';
export function ControlledInput() {
const [name, setName] = useState('');
const handleChange = (e) => {
setName(e.target.value);
};
return (
<div>
<input
type="text"
value={name}
onChange={handleChange}
placeholder="Enter your name"
/>
<p>You typed: {name}</p>
</div>
);
}Notice the value={name} prop. React is the source of truth. When state updates, the input re-renders with the new value. This gives you full control—you can validate, transform, debounce, or conditionally prevent updates.
Here's a more practical example with validation:
export function EmailInput() {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const handleChange = (e) => {
const value = e.target.value;
setEmail(value);
// Validate on every keystroke
if (value && !value.includes('@')) {
setError('Email must contain @');
} else {
setError('');
}
};
return (
<div>
<input
type="email"
value={email}
onChange={handleChange}
placeholder="your@email.com"
/>
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
);
}The controlled approach shines when you need the UI to react to every keystroke. You can show real-time validation, auto-format phone numbers, or update a sibling component based on input changes.
What are uncontrolled inputs?
In an uncontrolled input, the DOM owns the value. React doesn't manage it through state. Instead, you read the value when you need it—typically on form submission—using a ref or the FormData API.
Here's the uncontrolled pattern with a ref:
import { useRef } from 'react';
export function UncontrolledInput() {
const inputRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const value = inputRef.current.value;
console.log('Submitted:', value);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
ref={inputRef}
placeholder="Enter your name"
/>
<button type="submit">Submit</button>
</form>
);
}Notice there's no value prop, no onChange handler, and no state. The input manages its own value internally. You reach into the DOM with useRef and grab the value when needed.
The FormData API offers another way, especially useful for larger forms:
export function FormDataExample() {
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const values = Object.fromEntries(formData);
console.log(values); // { name: 'John', email: 'john@example.com' }
};
return (
<form onSubmit={handleSubmit}>
<input type="text" name="name" placeholder="Name" />
<input type="email" name="email" placeholder="Email" />
<button type="submit">Submit</button>
</form>
);
}This is simpler and cleaner for forms where you don't need to validate or react to every keystroke. The DOM is the source of truth until you explicitly read it.
Performance: uncontrolled forms are faster for large forms
Here's where uncontrolled inputs shine. When you have a form with 30, 50, or 100 fields, controlling every single one causes a state update and re-render on every keystroke in any field. React has to reconcile the entire component tree.
With uncontrolled inputs, the DOM updates directly—no React re-renders between keystrokes. You only trigger React logic on form submission.
Let me show the difference with a measurable example:
// Controlled: painful with many fields
export function ControlledForm() {
const [formData, setFormData] = useState({
field1: '', field2: '', field3: '', /* ...47 more fields */
});
const handleChange = (e) => {
setFormData(prev => ({
...prev,
[e.target.name]: e.target.value
}));
// This triggers a full re-render every keystroke
};
return (
<form>
{/* 50 inputs, each causes a re-render */}
<input name="field1" value={formData.field1} onChange={handleChange} />
{/* ...more inputs */}
</form>
);
}
// Uncontrolled: snappy
export function UncontrolledForm() {
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const values = Object.fromEntries(formData);
console.log(values);
// Only runs on submit
};
return (
<form onSubmit={handleSubmit}>
{/* 50 inputs, no re-renders until submit */}
<input type="text" name="field1" />
{/* ...more inputs */}
<button type="submit">Submit</button>
</form>
);
}I've used uncontrolled forms in a CRM application with hundreds of prospect fields. The performance difference was noticeable—typing in an uncontrolled form felt instant, while the controlled version lagged on older devices.
That said, most modern React applications don't hit this issue with 50+ fields. If you have a smaller form or need real-time validation, controlled inputs are the right choice.
Use controlled inputs when the UI reacts to every keystroke
Whenever the user's input changes how the rest of the page behaves, use controlled inputs. This includes:
- Real-time validation and error messages
- Auto-formatting (phone numbers, credit cards)
- Search results that update as you type
- Dependent fields (country → states → cities)
- Disabling a submit button until valid
Here's a real-world search example:
export function SearchUsers() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const handleSearch = async (e) => {
const value = e.target.value;
setQuery(value);
if (value.length > 2) {
const res = await fetch(`/api/users/search?q=${value}`);
const data = await res.json();
setResults(data);
} else {
setResults([]);
}
};
return (
<div>
<input
type="text"
value={query}
onChange={handleSearch}
placeholder="Search users..."
/>
<ul>
{results.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}The UI (the results list) depends on the input value. You need controlled inputs here.
Another example: a form where the submit button only enables when all fields are valid:
export function SignupForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const isValid = email.includes('@') && password.length >= 8;
return (
<form>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password (min 8 chars)"
/>
<button type="submit" disabled={!isValid}>
Sign up
</button>
</form>
);
}The button's disabled state depends on the input values. Controlled inputs are necessary.
Common mistakes
Mistake 1: Mixing controlled and uncontrolled in the same input. Don't do this:
// ❌ Wrong: value is undefined initially, then React takes over
const [value, setValue] = useState();
<input
value={value}
onChange={(e) => setValue(e.target.value)}
defaultValue="starting text"
/>If you have a value prop, you're controlling it. Remove defaultValue. If you want the input to start with a value, initialize your state:
// ✅ Correct
const [value, setValue] = useState('starting text');
<input
value={value}
onChange={(e) => setValue(e.target.value)}
/>Mistake 2: Using refs on controlled inputs. If you're already managing the value with state, you don't need a ref. Refs on controlled inputs are redundant and confusing:
// ❌ Unnecessary
const inputRef = useRef(null);
const [value, setValue] = useState('');
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
// Later: inputRef.current.value and value are out of syncMistake 3: Ignoring performance in large forms. If you have a form with many fields and no keystroke-dependent UI, don't force them all into state. Use uncontrolled inputs with FormData:
// ❌ Slow: 50 state updates per keystroke
const [form, setForm] = useState({ field1: '', field2: '', /* ...48 more */ });
// ✅ Fast: only validate/submit when ready
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
// Process all 50 fields at once
};Key takeaways
- Controlled inputs: React state owns the value via the
valueprop andonChangehandler. Use when the UI reacts to every keystroke. - Uncontrolled inputs: The DOM owns the value. Read it via refs or FormData on demand. Use for large forms or simple submissions.
- Uncontrolled forms are noticeably faster for forms with many fields because React doesn't re-render on every keystroke.
- Real-time validation, search results, auto-formatting, and dependent fields require controlled inputs.
- Never mix controlled and uncontrolled approaches in the same input. Pick one and stick with it.
- FormData is cleaner than manually mapping state for simple, large forms.
- If you're unsure, start with controlled inputs—they're safer and more predictable. Switch to uncontrolled only if performance becomes a problem.






Comments
Loading comments…