React Forms: Controlled vs Uncontrolled Components
You add a value prop to an input, open the page, and suddenly you can't type into it. Or the console starts yelling that "a component is changing an uncontrolled input to be controlled." Both problems come from the same place: React has two different ways of owning a form field, and it's easy to end up halfway between them. Those two ways are Controlled Components and Uncontrolled Components.
This post walks through both with working code, shows where each one fits, covers validation and performance, and lists the mistakes that cause those warnings in the first place.
- Understanding controlled vs uncontrolled components
- When to use each approach (with decision flowchart)
- Implementing controlled forms with useState
- Implementing uncontrolled forms with useRef
- Form validation techniques for both approaches
- Handling complex form scenarios
- Performance considerations and best practices
- Real-world examples with complete code
Understanding the Fundamentals
The whole difference comes down to one question: who holds the current value of the field, React or the browser?
Controlled Components
React state is the "single source of truth" for form data.
- Form data stored in
useState - Value attribute bound to state
- onChange handler updates state
- React controls the input
Uncontrolled Components
The DOM is the "single source of truth" for form data.
- Form data stored in the DOM
- Access via
useRef - Read values when needed
- DOM controls the input
Controlled components maintain state in React; uncontrolled components store data in the DOM
Controlled Components in Practice
With a controlled input, the field shows whatever is in state and nothing else. Every keystroke goes through your onChange handler, you update state, and React re-renders the input with the new value. That's more wiring, but it means you can read, change, or reject the value at any moment.
Basic Controlled Input
import { useState } from 'react';
function ControlledInput() {
// State holds the input value
const [name, setName] = useState('');
const handleChange = (event) => {
// Update state on every keystroke
setName(event.target.value);
};
const handleSubmit = (event) => {
event.preventDefault();
// State already has the current value
console.log('Submitted name:', name);
};
return (
<form onSubmit={handleSubmit}>
<label>
Name:
<input
type="text"
value={name} {/* Bound to state */}
onChange={handleChange} {/* Updates state */}
/>
</label>
<button type="submit">Submit</button>
<p>Current value: {name}</p>
</form>
);
}
The value attribute is always name from state, so what you see on screen and what's in state can't drift apart. The cost is a re-render of this component on every keystroke.
Handling Multiple Inputs
You don't need one useState and one handler per field. Give each input a name, keep everything in one object, and use a computed property key:
import { useState } from 'react';
function RegistrationForm() {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: '',
password: '',
agreeToTerms: false
});
// Single handler for all inputs
const handleChange = (event) => {
const { name, value, type, checked } = event.target;
setFormData(prev => ({
...prev,
// Use checked for checkboxes, value for others
[name]: type === 'checkbox' ? checked : value
}));
};
const handleSubmit = (event) => {
event.preventDefault();
console.log('Form data:', formData);
// API call, validation, etc.
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="firstName"
value={formData.firstName}
onChange={handleChange}
placeholder="First Name"
/>
<input
type="text"
name="lastName"
value={formData.lastName}
onChange={handleChange}
placeholder="Last Name"
/>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
/>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="Password"
/>
<label>
<input
type="checkbox"
name="agreeToTerms"
checked={formData.agreeToTerms}
onChange={handleChange}
/>
I agree to the terms
</label>
<button type="submit">Register</button>
</form>
);
}
Real-Time Validation with Controlled Components
Because every change passes through your code, you can validate or reformat input as the user types. This example waits until the field has been blurred once before showing errors, so people don't get yelled at on the first character:
import { useState } from 'react';
function ValidatedForm() {
const [email, setEmail] = useState('');
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
// Validation logic
const validateEmail = (value) => {
if (!value) {
return 'Email is required';
}
if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)) {
return 'Invalid email address';
}
return null;
};
const handleChange = (e) => {
const value = e.target.value;
setEmail(value);
// Real-time validation
if (touched.email) {
const error = validateEmail(value);
setErrors(prev => ({ ...prev, email: error }));
}
};
const handleBlur = () => {
setTouched(prev => ({ ...prev, email: true }));
const error = validateEmail(email);
setErrors(prev => ({ ...prev, email: error }));
};
const isValid = !errors.email && email.length > 0;
return (
<div>
<input
type="email"
value={email}
onChange={handleChange}
onBlur={handleBlur}
style={{ borderColor: errors.email ? 'red' : 'green' }}
/>
{errors.email && <span style={{ color: 'red' }}>{errors.email}</span>}
<button disabled={!isValid}>Submit</button>
</div>
);
}
Uncontrolled Components in Practice
Here you let the browser keep the value, the way plain HTML forms always have. No state, no handler per keystroke. You attach a ref and read the value when you actually need it, usually on submit.
Basic Uncontrolled Input
import { useRef } from 'react';
function UncontrolledInput() {
// Create a ref to access the DOM element
const inputRef = useRef(null);
const handleSubmit = (event) => {
event.preventDefault();
// Access the value directly from the DOM
console.log('Submitted name:', inputRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<label>
Name:
<input
type="text"
ref={inputRef} {/* Attach ref to element */}
defaultValue="John" {/* Initial value (not controlled) */}
/>
</label>
<button type="submit">Submit</button>
</form>
);
}
For an uncontrolled input, set the starting value with defaultValue, not value. If you pass value without onChange, the input becomes read-only and React logs "You provided a `value` prop to a form field without an `onChange` handler." Also note that changing defaultValue later does nothing; it only applies on mount. If you need to reset the field, change its key so React remounts it.
File Inputs: Uncontrolled Whether You Like It or Not
An <input type="file"> is always uncontrolled in React. Browsers don't let scripts set its value (for obvious security reasons), so you read files off a ref instead:
import { useRef, useState } from 'react';
function FileUpload() {
const fileInputRef = useRef(null);
const [selectedFile, setSelectedFile] = useState(null);
const handleSubmit = (event) => {
event.preventDefault();
const file = fileInputRef.current.files[0];
if (file) {
console.log('Selected file:', file.name);
console.log('Size:', file.size, 'bytes');
console.log('Type:', file.type);
// Upload logic here...
const formData = new FormData();
formData.append('file', file);
// fetch('/api/upload', { method: 'POST', body: formData });
}
};
const handleFileChange = () => {
const file = fileInputRef.current.files[0];
setSelectedFile(file ? file.name : null);
};
return (
<form onSubmit={handleSubmit}>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*,.pdf"
/>
{selectedFile && <p>Selected: {selectedFile}</p>}
<button type="submit">Upload</button>
</form>
);
}
Comparison Table
| Feature | Controlled | Uncontrolled |
|---|---|---|
| Data Storage | React state (useState) | DOM (via ref) |
| Value Access | Always available via state | Query DOM when needed |
| Real-time Validation | β Easy to implement | β Requires additional handlers |
| Instant Input Formatting | β Transform on every keystroke | β Not straightforward |
| Conditional Disable Submit | β Check state directly | β Need to track separately |
| Dynamic Inputs | β Add/remove fields easily | β οΈ More complex |
| Performance (many fields) | β οΈ Re-renders on each change | β No re-renders |
| Integration with React | β "React way" | β οΈ More imperative |
| File Inputs | β Not possible | β Only option |
| Code Complexity | More boilerplate | Less boilerplate |
When to Use Which?
Most of the time the answer falls out of what the form needs to do while the user is still typing. If nothing needs to react until submit, you probably don't need state. Here's the order I'd ask the questions in:
Use Controlled When:
- Real-time validation needed
- Input masking/formatting required
- Conditional rendering based on input
- Multi-step form wizards
- Form data drives other UI
Use Uncontrolled When:
- Simple forms, submit-only validation
- Integrating with non-React code
- File uploads
- Performance-critical scenarios
- Quick prototyping
A Fuller Example: Registration Form
Here's a controlled registration form with touched tracking, validation, and a submitting state, followed by the validators split into their own file and the same logic pulled into a useForm hook:
import { useState } from 'react';
function RegistrationForm() {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: ''
});
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const validate = (data) => {
const errors = {};
if (!data.username) {
errors.username = 'Username is required';
} else if (data.username.length < 3) {
errors.username = 'Username must be at least 3 characters';
}
if (!data.email) {
errors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(data.email)) {
errors.email = 'Email is invalid';
}
if (!data.password) {
errors.password = 'Password is required';
} else if (data.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
}
if (data.password !== data.confirmPassword) {
errors.confirmPassword = 'Passwords do not match';
}
return errors;
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Validate on change if field was touched
if (touched[name]) {
const newErrors = validate({ ...formData, [name]: value });
setErrors(prev => ({ ...prev, [name]: newErrors[name] }));
}
};
const handleBlur = (e) => {
const { name } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
const newErrors = validate(formData);
setErrors(prev => ({ ...prev, [name]: newErrors[name] }));
};
const handleSubmit = async (e) => {
e.preventDefault();
const validationErrors = validate(formData);
setErrors(validationErrors);
setTouched({
username: true,
email: true,
password: true,
confirmPassword: true
});
if (Object.keys(validationErrors).length === 0) {
setIsSubmitting(true);
try {
// API call here
await submitForm(formData);
alert('Registration successful!');
} catch (error) {
setErrors({ submit: error.message });
} finally {
setIsSubmitting(false);
}
}
};
const isFormValid = Object.keys(validate(formData)).length === 0;
return (
<form onSubmit={handleSubmit}>
{/* Form fields with error display */}
<button
type="submit"
disabled={!isFormValid || isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Register'}
</button>
</form>
);
}
// validators.js - Reusable validation functions
export const required = (value) =>
value ? undefined : 'This field is required';
export const minLength = (min) => (value) =>
value && value.length >= min
? undefined
: `Must be at least ${min} characters`;
export const maxLength = (max) => (value) =>
value && value.length <= max
? undefined
: `Must be ${max} characters or less`;
export const email = (value) =>
value && /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)
? undefined
: 'Invalid email address';
export const passwordStrength = (value) => {
if (!value) return undefined;
const hasUpperCase = /[A-Z]/.test(value);
const hasLowerCase = /[a-z]/.test(value);
const hasNumbers = /\d/.test(value);
const hasSpecial = /[!@#$%^&*]/.test(value);
if (!(hasUpperCase && hasLowerCase && hasNumbers && hasSpecial)) {
return 'Password must include uppercase, lowercase, number, and special character';
}
return undefined;
};
export const matches = (field, fieldName) => (value, allValues) =>
value === allValues[field]
? undefined
: `Must match ${fieldName}`;
// Compose multiple validators
export const composeValidators = (...validators) => (value, allValues) =>
validators.reduce(
(error, validator) => error || validator(value, allValues),
undefined
);
// useForm.js - Custom hook for form handling
import { useState, useCallback } from 'react';
export function useForm(initialValues, validate, onSubmit) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = useCallback((e) => {
const { name, value, type, checked } = e.target;
const fieldValue = type === 'checkbox' ? checked : value;
setValues(prev => {
const newValues = { ...prev, [name]: fieldValue };
if (touched[name] && validate) {
const newErrors = validate(newValues);
setErrors(newErrors);
}
return newValues;
});
}, [touched, validate]);
const handleBlur = useCallback((e) => {
const { name } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
if (validate) {
const newErrors = validate(values);
setErrors(newErrors);
}
}, [values, validate]);
const handleSubmit = useCallback(async (e) => {
e.preventDefault();
const validationErrors = validate ? validate(values) : {};
setErrors(validationErrors);
// Touch all fields
const allTouched = Object.keys(values).reduce(
(acc, key) => ({ ...acc, [key]: true }), {}
);
setTouched(allTouched);
if (Object.keys(validationErrors).length === 0) {
setIsSubmitting(true);
try {
await onSubmit(values);
} finally {
setIsSubmitting(false);
}
}
}, [values, validate, onSubmit]);
const reset = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
}, [initialValues]);
return {
values,
errors,
touched,
isSubmitting,
handleChange,
handleBlur,
handleSubmit,
reset,
setFieldValue: (name, value) => setValues(prev => ({ ...prev, [name]: value })),
setFieldError: (name, error) => setErrors(prev => ({ ...prev, [name]: error }))
};
}
Performance Considerations
For a login form, none of this matters. It starts to matter when a big form lives in one component and every keystroke re-renders all of it, or when typing triggers validation or network calls.
Controlled Component Performance Tips
- Debounce expensive operations: Don't validate or make API calls on every keystroke
- Use useMemo for computed values: Avoid recalculating validation on every render
- Split forms into components: Isolate re-renders to specific sections
- Consider useReducer: For complex forms with many fields
// Debounce example
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useDebounce(searchTerm, 300);
useEffect(() => {
if (debouncedSearch) {
fetchResults(debouncedSearch);
}
}, [debouncedSearch]);
When Uncontrolled Shines
Typing into an uncontrolled input doesn't re-render anything, which helps with:
- Very large forms (dozens of fields)
- Data grids with editable cells
- When integrating with third-party libraries
- Simple forms that only validate on submit
Common Pitfalls & Solutions
If a field's value starts as undefined and later becomes a string, React treats it as uncontrolled first and controlled after, and warns: "A component is changing an uncontrolled input to be controlled." This often happens when state is filled from an API response and some fields come back as null. Default them with value={user.phone ?? ''}.
// β BAD - undefined to string
const [value, setValue] = useState(); // undefined!
<input value={value} />
// β
GOOD - always a string
const [value, setValue] = useState('');
<input value={value} />
Using value without onChange makes the input read-only. Use defaultValue for uncontrolled inputs.
// β BAD - read-only input
<input value="fixed" ref={inputRef} />
// β
GOOD - editable uncontrolled input
<input defaultValue="editable" ref={inputRef} />
If one big component holds all the state, every keystroke re-renders the whole form. Pull fields into their own components and memoize them, but remember React.memo only helps if the onChange you pass down is stable (wrapped in useCallback).
// β
GOOD - Isolated component
function EmailField({ value, onChange, error }) {
return (
<div>
<input value={value} onChange={onChange} />
{error && <span>{error}</span>}
</div>
);
}
// Wrap with React.memo if needed
export default React.memo(EmailField);
Habits That Keep Forms Sane
'' (or false for checkboxes), never undefined, so inputs don't flip modes.name attribute with a computed property key.useForm hook pays for itself.When I'd Pick Each (and When I Wouldn't)
I reach for controlled inputs when something on screen depends on the value while the user is typing: a live character count, a formatted phone number, a submit button that enables only when the form is valid, or fields that appear based on earlier answers. I wouldn't bother for a search box that only fires on Enter or a simple contact form validated on submit. There, a ref or plain FormData from the submit event is less code and nothing re-renders.
Uncontrolled is the right call for file inputs, for wrapping non-React widgets that manage their own DOM, and for very large forms where re-rendering on every key becomes noticeable. I'd avoid it when other parts of the UI need the value, or when you have to reset or prefill fields after mount, since defaultValue won't help you there.
Once a form has many fields, cross-field rules, and async validation, I stop writing either version by hand. React Hook Form uses uncontrolled inputs under the hood with refs and still gives you a validation API that feels controlled, which is usually the better trade than managing a dozen useState calls yourself.