Controlled vs Uncontrolled: React Form State Patterns
Every React developer works with forms. Whether you're building a login page, a search bar, or a complex multi-step wizard, understanding how to manage form state is fundamental. React offers two distinct approaches: controlled components where React manages state, and uncontrolled components where the DOM handles it. Choosing the right pattern dramatically affects your application's performance, maintainability, and user experience.
Table of Contents
- Understanding Form State Management
- Controlled Components Explained
- Uncontrolled Components Explained
- Performance Comparison
- When to Use Each Pattern
- Advanced: Hybrid Approaches
- Common Patterns and Pitfalls
- FAQ
Understanding Form State Management
React's philosophy is declarative—you describe what the UI should look like for a given state. With forms, you have two ways to keep your described UI in sync with actual form values.
In a controlled component, React state is the "single source of truth." Every keystroke updates state, and the input's value comes from that state. In an uncontrolled component, the DOM itself holds the value, and React doesn't track it in state until you explicitly ask for it.
The choice between these patterns affects not just code style, but performance, validation timing, and debugging difficulty. Let's explore both deeply.
Controlled Components Explained
The Controlled Component Pattern
A controlled component is one where React state controls the value of form elements. Every time the user types, you update state, and React re-renders the input with the new value.
Basic Controlled Input
TypeScript Version
import { useState } from 'react';
export function ControlledInput() {
const [email, setEmail] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setEmail(e.currentTarget.value);
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log('Submitted email:', email);
setEmail(''); // Clear after submit
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
value={email}
onChange={handleChange}
placeholder="Enter your email"
/>
<button type="submit">Submit</button>
</form>
);
}
JavaScript Version
import { useState } from 'react';
export function ControlledInput() {
const [email, setEmail] = useState('');
const handleChange = (e) => {
setEmail(e.currentTarget.value);
};
const handleSubmit = (e) => {
e.preventDefault();
console.log('Submitted email:', email);
setEmail('');
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
value={email}
onChange={handleChange}
placeholder="Enter your email"
/>
<button type="submit">Submit</button>
</form>
);
}
Controlled Inputs with Validation
The power of controlled components shines with real-time validation:
TypeScript Version
import { useState } from 'react';
interface FormState {
email: string;
password: string;
errors: { email?: string; password?: string };
}
export function LoginForm() {
const [form, setForm] = useState<FormState>({
email: '',
password: '',
errors: {},
});
const validateEmail = (email: string): string | undefined => {
if (!email.includes('@')) {
return 'Invalid email format';
}
return undefined;
};
const validatePassword = (password: string): string | undefined => {
if (password.length < 8) {
return 'Password must be at least 8 characters';
}
return undefined;
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement>,
field: 'email' | 'password'
) => {
const value = e.currentTarget.value;
const validator = field === 'email' ? validateEmail : validatePassword;
const error = validator(value);
setForm({
...form,
[field]: value,
errors: { ...form.errors, [field]: error },
});
};
const isFormValid = !form.errors.email && !form.errors.password;
return (
<form className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
value={form.email}
onChange={(e) => handleChange(e, 'email')}
className={`w-full px-3 py-2 border rounded ${
form.errors.email ? 'border-red-500' : 'border-gray-300'
}`}
/>
{form.errors.email && (
<p className="text-red-600 text-sm mt-1">{form.errors.email}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
value={form.password}
onChange={(e) => handleChange(e, 'password')}
className={`w-full px-3 py-2 border rounded ${
form.errors.password ? 'border-red-500' : 'border-gray-300'
}`}
/>
{form.errors.password && (
<p className="text-red-600 text-sm mt-1">{form.errors.password}</p>
)}
</div>
<button
type="submit"
disabled={!isFormValid}
className="w-full px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
>
Login
</button>
</form>
);
}
JavaScript Version
import { useState } from 'react';
export function LoginForm() {
const [form, setForm] = useState({
email: '',
password: '',
errors: {},
});
const validateEmail = (email) => {
if (!email.includes('@')) {
return 'Invalid email format';
}
return undefined;
};
const validatePassword = (password) => {
if (password.length < 8) {
return 'Password must be at least 8 characters';
}
return undefined;
};
const handleChange = (e, field) => {
const value = e.currentTarget.value;
const validator = field === 'email' ? validateEmail : validatePassword;
const error = validator(value);
setForm({
...form,
[field]: value,
errors: { ...form.errors, [field]: error },
});
};
const isFormValid = !form.errors.email && !form.errors.password;
return (
<form className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
value={form.email}
onChange={(e) => handleChange(e, 'email')}
className={`w-full px-3 py-2 border rounded ${
form.errors.email ? 'border-red-500' : 'border-gray-300'
}`}
/>
{form.errors.email && (
<p className="text-red-600 text-sm mt-1">{form.errors.email}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
Password
</label>
<input
id="password"
type="password"
value={form.password}
onChange={(e) => handleChange(e, 'password')}
className={`w-full px-3 py-2 border rounded ${
form.errors.password ? 'border-red-500' : 'border-gray-300'
}`}
/>
{form.errors.password && (
<p className="text-red-600 text-sm mt-1">{form.errors.password}</p>
)}
</div>
<button
type="submit"
disabled={!isFormValid}
className="w-full px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"
>
Login
</button>
</form>
);
}
Advantages of Controlled Components
React manages the single source of truth, making it easy to validate in real-time, disable buttons conditionally, and clear fields on demand. You can access field values synchronously without reaching into the DOM.
Uncontrolled Components Explained
The Uncontrolled Component Pattern
With uncontrolled components, you let the DOM manage the value. React doesn't track changes in state. Instead, you extract values when needed using refs or the FormData API.
Basic Uncontrolled Input with Refs
TypeScript Version
import { useRef } from 'react';
export function UncontrolledInput() {
const emailRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (emailRef.current) {
console.log('Submitted email:', emailRef.current.value);
emailRef.current.value = ''; // Manually clear
}
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
ref={emailRef}
placeholder="Enter your email"
defaultValue=""
/>
<button type="submit">Submit</button>
</form>
);
}
JavaScript Version
import { useRef } from 'react';
export function UncontrolledInput() {
const emailRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
if (emailRef.current) {
console.log('Submitted email:', emailRef.current.value);
emailRef.current.value = '';
}
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
ref={emailRef}
placeholder="Enter your email"
defaultValue=""
/>
<button type="submit">Submit</button>
</form>
);
}
Key difference: Notice defaultValue instead of value. The input now owns its value, not React.
Uncontrolled Forms with FormData API
Modern browsers provide the FormData API, making uncontrolled forms elegant:
TypeScript Version
import { useState } from 'react';
interface ContactData {
name: string;
email: string;
message: string;
subscribe: boolean;
}
export function ContactForm() {
const [submitted, setSubmitted] = useState(false);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data: ContactData = {
name: formData.get('name') as string,
email: formData.get('email') as string,
message: formData.get('message') as string,
subscribe: formData.get('subscribe') === 'on',
};
console.log('Form data:', data);
setSubmitted(true);
// Reset form
e.currentTarget.reset();
};
return (
<div>
{submitted && (
<p className="text-green-600 mb-4">Thank you! Message sent.</p>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium">
Name
</label>
<input
id="name"
name="name"
type="text"
required
className="w-full px-3 py-2 border border-gray-300 rounded"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full px-3 py-2 border border-gray-300 rounded"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium">
Message
</label>
<textarea
id="message"
name="message"
rows={5}
required
className="w-full px-3 py-2 border border-gray-300 rounded"
/>
</div>
<label className="flex items-center">
<input
name="subscribe"
type="checkbox"
className="w-4 h-4"
/>
<span className="ml-2">Subscribe to updates</span>
</label>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Send Message
</button>
</form>
</div>
);
}
JavaScript Version
import { useState } from 'react';
export function ContactForm() {
const [submitted, setSubmitted] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const data = {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
subscribe: formData.get('subscribe') === 'on',
};
console.log('Form data:', data);
setSubmitted(true);
e.currentTarget.reset();
};
return (
<div>
{submitted && (
<p className="text-green-600 mb-4">Thank you! Message sent.</p>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium">
Name
</label>
<input
id="name"
name="name"
type="text"
required
className="w-full px-3 py-2 border border-gray-300 rounded"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
name="email"
type="email"
required
className="w-full px-3 py-2 border border-gray-300 rounded"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium">
Message
</label>
<textarea
id="message"
name="message"
rows={5}
required
className="w-full px-3 py-2 border border-gray-300 rounded"
/>
</div>
<label className="flex items-center">
<input
name="subscribe"
type="checkbox"
className="w-4 h-4"
/>
<span className="ml-2">Subscribe to updates</span>
</label>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Send Message
</button>
</form>
</div>
);
}
Advantages of uncontrolled forms: Less state management, no re-renders on every keystroke, simpler code for simple forms.
Performance Comparison
Rendering Behavior: Controlled vs Uncontrolled
Let's visualize what happens during user input:
Controlled Component:
- User types in input
onChangefires- State updates (
setEmail) - Component re-renders
- Input gets new
valuefrom state - Total: Full component re-render on every keystroke
Uncontrolled Component:
- User types in input
- DOM updates the input value directly
- No state change, no re-render
- Total: Zero re-renders during input
Real Performance Impact
For a simple form with one or two inputs, the difference is negligible. But in complex forms, controlled components cause measurable performance issues:
// PROBLEMATIC: Every keystroke triggers re-render of entire form
function ComplexForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [address, setAddress] = useState('');
const [zipCode, setZipCode] = useState('');
const [terms, setTerms] = useState(false);
// ...15 more fields
return (
<form>
{/* Complex calculations happen on every keystroke */}
{expensiveValidation(email, password)}
{/* Multiple dependent fields re-render */}
{/* All your form UI re-renders for every keystroke */}
</form>
);
}
Uncontrolled approach is more efficient here.
When to Measure Performance
Use React DevTools Profiler to check if your form is problematic:
// Enable in React DevTools → Profiler tab
// Fill out form and check:
// - Are components re-rendering on every keystroke?
// - How long does each render take?
// - Is the render work justified?
function MeasuredForm() {
const [formData, setFormData] = useState({
// many fields
});
// If Profiler shows excessive renders, consider uncontrolled
return <form>{/* ... */}</form>;
}
When to Use Each Pattern
Use Controlled Components When:
You need real-time validation or feedback as the user types. You want to disable/enable buttons based on form state. You need to dynamically show/hide fields based on other field values. You're building a multi-step form where state persists between steps. You need to implement features like "unsaved changes" warnings. You're integrating with a form state library (react-hook-form, Formik).
// Controlled is better here - need real-time validation
function PasswordChecker() {
const [password, setPassword] = useState('');
const strength = calculateStrength(password);
return (
<>
<input
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<div className={`strength-${strength}`}>
Strength: {strength}
</div>
</>
);
}
Use Uncontrolled Components When:
The form is simple with minimal validation logic. You only need values on form submission. You're building a basic search, filter, or contact form. You're dealing with file inputs (which can't be controlled). Performance is critical and you have many form fields.
// Uncontrolled is better here - simple form, submit only
function SearchForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const query = formData.get('search') as string;
performSearch(query);
};
return (
<form onSubmit={handleSubmit}>
<input name="search" type="text" placeholder="Search..." />
<button type="submit">Search</button>
</form>
);
}
Advanced: Hybrid Approaches
Mostly Uncontrolled with Validation on Submit
Get simplicity while keeping validation:
TypeScript Version
import { useRef } from 'react';
interface FormErrors {
email?: string;
password?: string;
}
export function HybridForm() {
const formRef = useRef<HTMLFormElement>(null);
const [errors, setErrors] = useRef<FormErrors>({}).current;
const validate = (formData: FormData): FormErrors => {
const errors: FormErrors = {};
const email = formData.get('email') as string;
const password = formData.get('password') as string;
if (!email.includes('@')) {
errors.email = 'Invalid email';
}
if (password.length < 8) {
errors.password = 'Password too short';
}
return errors;
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const validationErrors = validate(formData);
if (Object.keys(validationErrors).length === 0) {
// Submit valid form
console.log('Submitting:', Object.fromEntries(formData));
} else {
setErrors(validationErrors);
}
};
return (
<form ref={formRef} onSubmit={handleSubmit}>
<input name="email" type="email" defaultValue="" />
{errors.email && <p>{errors.email}</p>}
<input name="password" type="password" defaultValue="" />
{errors.password && <p>{errors.password}</p>}
<button type="submit">Submit</button>
</form>
);
}
JavaScript Version
import { useRef } from 'react';
export function HybridForm() {
const formRef = useRef(null);
const errors = useRef({}).current;
const validate = (formData) => {
const errors = {};
const email = formData.get('email');
const password = formData.get('password');
if (!email.includes('@')) {
errors.email = 'Invalid email';
}
if (password.length < 8) {
errors.password = 'Password too short';
}
return errors;
};
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const validationErrors = validate(formData);
if (Object.keys(validationErrors).length === 0) {
console.log('Submitting:', Object.fromEntries(formData));
} else {
errors = validationErrors;
}
};
return (
<form ref={formRef} onSubmit={handleSubmit}>
<input name="email" type="email" defaultValue="" />
{errors.email && <p>{errors.email}</p>}
<input name="password" type="password" defaultValue="" />
{errors.password && <p>{errors.password}</p>}
<button type="submit">Submit</button>
</form>
);
}
Using Form State Libraries
For complex forms, libraries like react-hook-form provide the best of both worlds:
import { useForm } from 'react-hook-form';
interface SignupData {
email: string;
password: string;
confirmPassword: string;
}
export function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm<SignupData>();
const onSubmit = (data: SignupData) => {
console.log('Valid data:', data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<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>
);
}
Libraries like react-hook-form use an uncontrolled approach internally but manage validation and state for you.
Common Patterns and Pitfalls
Pitfall 1: Mixing Controlled and Uncontrolled
// ❌ WRONG: Changing from uncontrolled to controlled mid-render
function BadForm() {
const [isControlled, setIsControlled] = useState(false);
// This causes React warnings
return (
<input
value={isControlled ? 'some value' : undefined}
onChange={(e) => console.log(e.currentTarget.value)}
/>
);
}
// ✅ CORRECT: Pick one approach
function GoodForm() {
const [email, setEmail] = useState('');
return (
<input
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
/>
);
}
Pitfall 2: Forgetting preventDefault on Form Submit
// ❌ WRONG: Page refreshes
const handleSubmit = (e) => {
console.log('Submitting...');
// Oops, forgot e.preventDefault()
};
// ✅ CORRECT: Prevent default form behavior
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
console.log('Submitting...');
};
Pitfall 3: Unnecessary State in Simple Forms
// ❌ WRONG: State overhead for simple form
function SearchForm() {
const [query, setQuery] = useState('');
return (
<form>
<input
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
{/* Renders on every keystroke for no reason */}
</form>
);
}
// ✅ CORRECT: Uncontrolled for simple forms
function SearchForm() {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const query = formData.get('search');
// Only runs on submit
};
return (
<form onSubmit={handleSubmit}>
<input name="search" />
<button>Search</button>
</form>
);
}
FAQ
Q: Should I always use controlled components?
A: No. Controlled components add overhead with re-renders on every keystroke. Use them when you genuinely need real-time validation, conditional field display, or other state-driven features. For simple forms, uncontrolled is simpler and faster.
Q: Can I convert between controlled and uncontrolled?
A: Only at the component level. If you start rendering an input with value (controlled), you can't suddenly stop providing it—React will warn you. Create separate components if you need both patterns.
Q: What about file inputs?
A: File inputs are always uncontrolled. You can't set their value from JavaScript for security reasons. Use refs to access the FileList:
const fileRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
const files = fileRef.current?.files;
};
Q: How do I validate a controlled component in real-time?
A: Update state on change and display errors immediately:
const [email, setEmail] = useState('');
const error = !email.includes('@') ? 'Invalid email' : null;
return (
<>
<input value={email} onChange={(e) => setEmail(e.currentTarget.value)} />
{error && <p className="text-red-600">{error}</p>}
</>
);
Q: Is FormData browser support an issue?
A: FormData is supported in all modern browsers (including IE11 with polyfill). For older browser support, use refs to read individual inputs.
Q: How do I set a default value in uncontrolled components?
A: Use defaultValue or defaultChecked for inputs and checkboxes:
<input name="email" type="email" defaultValue="default@example.com" />
<textarea name="message" defaultValue="Default message" />
<input type="checkbox" defaultChecked={true} />
Key Takeaway: Choose controlled components when React needs to know about every state change, and uncontrolled components when the form is simple enough that you only need values on submit. Performance-conscious developers often reach for uncontrolled patterns first and only add controlled behavior when genuinely needed.
Questions? Which pattern do you prefer? Share your form architecture in the comments below.
Google AdSense Placeholder
CONTENT SLOT