Introduction
When working with forms in React, you'll encounter two fundamental approaches to handle form inputs: controlled components and uncontrolled components. Understanding the differences, trade-offs, and appropriate use cases for each approach is crucial for building effective React applications.
Real-world analogy: Think of controlled vs. uncontrolled components like two different ways to manage a notepad. With a controlled component, it's like having someone write down exactly what you dictate (React state manages everything). With an uncontrolled component, it's like giving someone a notepad and checking later what they've written (the DOM manages the state until you need it).
Controlled Components
Definition
A controlled component is a form element whose value is controlled by React state. The component receives its current value as a prop and includes a callback to change that value. This makes React the "single source of truth" for the component's state.
How Controlled Components Work
- Form data is stored in the component's state
- Input value is set from the state
- When user types, onChange handler updates the state
- Component re-renders with the new value
- Current form data is always available in your component's state
Basic Example
import React, { useState } from 'react';
function ControlledInput() {
const [inputValue, setInputValue] = useState('');
const handleChange = (event) => {
setInputValue(event.target.value);
};
return (
<div>
<input
type="text"
value={inputValue}
onChange={handleChange}
placeholder="Type something..."
/>
<p>You typed: {inputValue}</p>
</div>
);
}
Advantages of Controlled Components
- Immediate access to input value - The current value is always available in your state
- Predictable behavior - React is the single source of truth for the input's value
- Real-time validation - You can validate or transform input as the user types
- Conditional rendering - You can show/hide elements based on current input values
- Dynamic inputs - You can dynamically change the input behavior based on other inputs
- Form orchestration - Easier to coordinate between multiple inputs
Disadvantages of Controlled Components
- More code - Requires state and event handlers for each input
- Performance considerations - Every keystroke triggers a re-render
- Complexity - Can be overkill for very simple forms
Uncontrolled Components
Definition
An uncontrolled component is a form element that maintains its own internal state. Instead of updating state on every change, you use a ref to get the field's value when needed (typically during form submission).
How Uncontrolled Components Work
- Form data is handled by the DOM itself
- You provide a default value (if needed) via the defaultValue prop
- You create a ref to access the DOM element
- You read the value from the ref when you need it (e.g., on form submission)
- React doesn't re-render on every keystroke
Basic Example
import React, { useRef } from 'react';
function UncontrolledInput() {
const inputRef = useRef(null);
const handleSubmit = (event) => {
event.preventDefault();
alert(`You entered: ${inputRef.current.value}`);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
ref={inputRef}
defaultValue=""
placeholder="Type something..."
/>
<button type="submit">Submit</button>
</form>
);
}
Advantages of Uncontrolled Components
- Simplicity - Less code for simple forms
- Performance - No re-renders on every keystroke
- Integration - Easier to integrate with non-React code or third-party libraries
- Direct DOM access - Useful for certain operations like focusing or selecting text
Disadvantages of Uncontrolled Components
- Less control - Can't easily validate or transform input as the user types
- Delayed access - You only get the value when you explicitly ask for it
- Harder coordination - More difficult to coordinate between multiple inputs
- Limited reactivity - Can't easily react to input changes to show/hide other elements
Key Differences: At a Glance
| Aspect | Controlled Components | Uncontrolled Components |
|---|---|---|
| Source of Truth | React State | DOM |
| Data Access | Immediate (via state) | On-demand (via refs) |
| Value Setting | value prop |
defaultValue prop |
| Change Handling | onChange handler |
Not required |
| Re-renders | On every change | Only when explicitly triggered |
| Form Validation | Real-time | On submission (typically) |
| Code Volume | More | Less |
Code Comparison
Controlled Component
function ControlledForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const handleNameChange = (e) => {
setName(e.target.value);
};
const handleEmailChange = (e) => {
setEmail(e.target.value);
};
const handleSubmit = (e) => {
e.preventDefault();
console.log({ name, email });
};
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={handleNameChange}
/>
<input
value={email}
onChange={handleEmailChange}
/>
<button type="submit">Submit</button>
</form>
);
}
Uncontrolled Component
function UncontrolledForm() {
const nameRef = useRef();
const emailRef = useRef();
const handleSubmit = (e) => {
e.preventDefault();
console.log({
name: nameRef.current.value,
email: emailRef.current.value
});
};
return (
<form onSubmit={handleSubmit}>
<input ref={nameRef} defaultValue="" />
<input ref={emailRef} defaultValue="" />
<button type="submit">Submit</button>
</form>
);
}
When to Use Each Approach
Use Controlled Components When:
- You need to validate input as the user types
- You need to conditionally disable the submit button (e.g., until a form is valid)
- You need to enforce a specific input format (e.g., credit card formatting)
- You need to instantly react to user input to show/hide elements
- You're building dynamic forms where fields affect each other
- You need to submit the form data asynchronously
- You want to implement features like auto-save
Use Uncontrolled Components When:
- You're building a simple form where validation on submission is sufficient
- You're integrating with third-party DOM libraries
- You're working with file inputs
- You're integrating React into a non-React codebase
- Performance is a concern for forms with many fields
- You don't need real-time access to the input's value
access to value?} -->|Yes| B[Controlled] A -->|No| C[Uncontrolled] B --> D{Need instant
validation?} D -->|Yes| E[Controlled] D -->|No| F{Many input
fields?} F -->|Yes| G{Performance
concerns?} F -->|No| E G -->|Yes| H[Consider
Uncontrolled] G -->|No| E C --> I{File uploads?} I -->|Yes| J[Uncontrolled] I -->|No| K{Third-party
library?} K -->|Yes| J K -->|No| L{Simple
form?} L -->|Yes| J L -->|No| M[Consider
Controlled]
Special Cases & Hybrid Approaches
File Inputs
File inputs are inherently uncontrolled in React because their value is read-only for security reasons:
function FileUploader() {
const fileInputRef = useRef(null);
const [fileName, setFileName] = useState('');
const handleFileChange = (event) => {
const file = event.target.files[0];
if (file) {
setFileName(file.name);
}
};
return (
<div>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
/>
{fileName && <p>Selected file: {fileName}</p>}
<button
onClick={() => fileInputRef.current.click()}
type="button"
>
Select File
</button>
</div>
);
}
In this example, the file input itself is uncontrolled (we use a ref to access it),
but we maintain a piece of controlled state (fileName) to display information about the selected file.
Hybrid Approach with Lazy Initialization
Sometimes you may want to initialize a controlled component from an external source. Here's a pattern for creating a controlled component from an uncontrolled value:
function ProfileEditor({ userId }) {
const [profile, setProfile] = useState(null);
const [loading, setLoading] = useState(true);
// Fetch initial data
useEffect(() => {
async function fetchProfile() {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setProfile(data);
} catch (error) {
console.error('Error fetching profile:', error);
} finally {
setLoading(false);
}
}
fetchProfile();
}, [userId]);
const handleChange = (e) => {
const { name, value } = e.target;
setProfile(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(profile)
});
alert('Profile updated!');
} catch (error) {
console.error('Error updating profile:', error);
}
};
if (loading) return <div>Loading...</div>;
if (!profile) return <div>Error loading profile</div>;
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="displayName">Display Name:</label>
<input
id="displayName"
name="displayName"
value={profile.displayName}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="bio">Bio:</label>
<textarea
id="bio"
name="bio"
value={profile.bio}
onChange={handleChange}
/>
</div>
<button type="submit">Save Profile</button>
</form>
);
}
This pattern is useful when:
- You need to initialize form data from an API or other external source
- You want the benefits of controlled components after initialization
- You're building forms for editing existing data
Using Refs with Controlled Components
Sometimes you need DOM manipulation with controlled components, such as focusing an input:
function SearchForm() {
const [query, setQuery] = useState('');
const inputRef = useRef(null);
const handleChange = (e) => {
setQuery(e.target.value);
};
const handleClear = () => {
setQuery('');
// Focus the input after clearing
inputRef.current.focus();
};
return (
<div>
<input
type="text"
value={query}
onChange={handleChange}
ref={inputRef}
placeholder="Search..."
/>
{query && (
<button onClick={handleClear} type="button">
Clear
</button>
)}
</div>
);
}
This combines the best of both worlds: controlled state management with direct DOM access when needed.
Optimizing Performance
Performance Considerations for Controlled Components
Since controlled components re-render on every change, they can potentially impact performance, especially in forms with many fields. Here are some optimization strategies:
Debouncing Input Updates
import { useState, useEffect, useCallback } from 'react';
import debounce from 'lodash/debounce';
function DebouncedInput() {
// State for immediate UI updates
const [inputValue, setInputValue] = useState('');
// State for debounced operations (e.g., API calls)
const [debouncedValue, setDebouncedValue] = useState('');
// Create a debounced function (created only once)
const debouncedSetValue = useCallback(
debounce(value => {
setDebouncedValue(value);
console.log('Debounced update:', value);
// Perform expensive operations here
}, 500),
[] // Empty dependency array means this is created only once
);
// Handle input changes
const handleChange = (e) => {
const value = e.target.value;
setInputValue(value); // Update UI immediately
debouncedSetValue(value); // Schedule debounced update
};
// Cleanup on unmount
useEffect(() => {
return () => {
debouncedSetValue.cancel();
};
}, [debouncedSetValue]);
return (
<div>
<input
type="text"
value={inputValue}
onChange={handleChange}
placeholder="Type to search..."
/>
<p>Immediate value: {inputValue}</p>
<p>Debounced value: {debouncedValue}</p>
</div>
);
}
Optimizing Multiple Fields with useMemo
function OptimizedForm() {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: '',
// More fields...
});
// Memoize the validation result
const validationResult = useMemo(() => {
// Expensive validation logic
const errors = {};
if (!formData.firstName) {
errors.firstName = 'First name is required';
}
if (!formData.email) {
errors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
errors.email = 'Email is invalid';
}
// More validations...
return {
isValid: Object.keys(errors).length === 0,
errors
};
}, [formData]); // Only recalculate when formData changes
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
return (
<form>
<div>
<input
name="firstName"
value={formData.firstName}
onChange={handleChange}
/>
{validationResult.errors.firstName && (
<p className="error">{validationResult.errors.firstName}</p>
)}
</div>
<div>
<input
name="email"
value={formData.email}
onChange={handleChange}
/>
{validationResult.errors.email && (
<p className="error">{validationResult.errors.email}</p>
)}
</div>
<button
type="submit"
disabled={!validationResult.isValid}
>
Submit
</button>
</form>
);
}
Real-World Examples
Login Form (Controlled)
function LoginForm() {
const [credentials, setCredentials] = useState({
username: '',
password: ''
});
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (e) => {
const { name, value } = e.target;
setCredentials(prev => ({
...prev,
[name]: value
}));
// Clear errors when user types
if (errors[name]) {
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const validate = () => {
const newErrors = {};
if (!credentials.username.trim()) {
newErrors.username = 'Username is required';
}
if (!credentials.password) {
newErrors.password = 'Password is required';
} else if (credentials.password.length < 6) {
newErrors.password = 'Password must be at least 6 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) return;
setIsSubmitting(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Here you would actually call your authentication API
console.log('Logging in with:', credentials);
// On success, you might redirect or update state
alert('Login successful!');
} catch (error) {
setErrors({ form: 'Login failed. Please try again.' });
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="login-form">
Login
{errors.form && (
<div className="error-message">{errors.form}</div>
)}
<div className="form-group">
<label htmlFor="username">Username</label>
<input
id="username"
name="username"
type="text"
value={credentials.username}
onChange={handleChange}
className={errors.username ? 'error' : ''}
disabled={isSubmitting}
/>
{errors.username && (
<div className="error-message">{errors.username}</div>
)}
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
name="password"
type="password"
value={credentials.password}
onChange={handleChange}
className={errors.password ? 'error' : ''}
disabled={isSubmitting}
/>
{errors.password && (
<div className="error-message">{errors.password}</div>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="login-button"
>
{isSubmitting ? 'Logging in...' : 'Log In'}
</button>
</form>
);
}
Comment Form (Uncontrolled)
function CommentForm({ postId, onCommentAdded }) {
const nameRef = useRef(null);
const commentRef = useRef(null);
const formRef = useRef(null);
const handleSubmit = async (e) => {
e.preventDefault();
const name = nameRef.current.value.trim();
const comment = commentRef.current.value.trim();
// Basic validation
if (!name || !comment) {
alert('Please fill in all fields');
return;
}
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
// Here you would actually submit to your API
console.log('Adding comment:', { postId, name, comment });
// Notify parent component
onCommentAdded({ name, comment, date: new Date() });
// Reset form
formRef.current.reset();
} catch (error) {
console.error('Error adding comment:', error);
alert('Failed to add comment. Please try again.');
}
};
return (
<form ref={formRef} onSubmit={handleSubmit} className="comment-form">
<h3>Add a Comment</h3>
<div className="form-group">
<label htmlFor="name">Name</label>
<input
id="name"
ref={nameRef}
type="text"
defaultValue=""
required
/>
</div>
<div className="form-group">
<label htmlFor="comment">Comment</label>
<textarea
id="comment"
ref={commentRef}
rows="4"
defaultValue=""
required
/>
</div>
<button type="submit" className="submit-button">
Post Comment
</button>
</form>
);
}
Search Form with Hybrid Approach
function SearchComponent() {
// Controlled state for the input
const [query, setQuery] = useState('');
// State for search results
const [results, setResults] = useState([]);
// State for loading indicator
const [isLoading, setIsLoading] = useState(false);
// Ref for input element (for focus management)
const inputRef = useRef(null);
// Create a debounced search function
const debouncedSearch = useCallback(
debounce(async (searchTerm) => {
if (!searchTerm.trim()) {
setResults([]);
return;
}
setIsLoading(true);
try {
// Simulate API call
const response = await fetch(
`https://api.example.com/search?q=${encodeURIComponent(searchTerm)}`
);
const data = await response.json();
setResults(data.results);
} catch (error) {
console.error('Search error:', error);
setResults([]);
} finally {
setIsLoading(false);
}
}, 300),
[]
);
// Trigger search when query changes
useEffect(() => {
debouncedSearch(query);
// Cleanup on unmount
return () => {
debouncedSearch.cancel();
};
}, [query, debouncedSearch]);
const handleChange = (e) => {
setQuery(e.target.value);
};
const handleClear = () => {
setQuery('');
setResults([]);
inputRef.current.focus();
};
// Handle result selection
const handleResultClick = (result) => {
console.log('Selected result:', result);
// Implement your selection logic here
};
return (
<div className="search-container">
<div className="search-input-wrapper">
<input
type="text"
value={query}
onChange={handleChange}
ref={inputRef}
placeholder="Search..."
className="search-input"
/>
{query && (
<button
onClick={handleClear}
className="clear-button"
type="button"
aria-label="Clear search"
>
×
</button>
)}
</div>
{isLoading && (
<div className="loading-indicator">Searching...</div>
)}
{!isLoading && results.length > 0 && (
<ul className="search-results">
{results.map(result => (
<li
key={result.id}
onClick={() => handleResultClick(result)}
className="result-item"
>
<div className="result-title">{result.title}</div>
<div className="result-description">{result.description}</div>
</li>
))}
</ul>
)}
{!isLoading && query && results.length === 0 && (
<div className="no-results">No results found</div>
)}
</div>
);
}
Practice Exercises
Exercise 1: Controlled to Uncontrolled Conversion
Take a controlled form component and convert it to an uncontrolled version. Pay attention to the differences in how you handle form submission and validation.
// Starting with this controlled component:
function ControlledContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = (e) => {
e.preventDefault();
console.log('Form data:', formData);
};
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Name"
/>
<input
name="email"
type="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
/>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Message"
/>
<button type="submit">Send</button>
</form>
);
}
// Convert it to an uncontrolled component
Exercise 2: Implement a Form with Both Approaches
Create a user registration form with the following fields:
- Username (controlled - with real-time validation)
- Email (controlled - with real-time validation)
- Password (controlled - with password strength indicator)
- Profile picture (uncontrolled - file input)
- Bio (uncontrolled - large text area)
Implement proper validation and error handling. Show how to combine both approaches in a single form.
Exercise 3: Performance Optimization
Create a large form with at least 10 fields using controlled components. Then optimize it for performance using the techniques discussed (debouncing, memoization, etc.). Compare the performance before and after optimization.
Summary
In this lecture, we've explored the two fundamental approaches to handling form components in React:
- Controlled components use React state as the source of truth, providing real-time access to form data and enabling features like immediate validation and dynamic interfaces.
- Uncontrolled components let the DOM handle form state, accessed through refs when needed, offering simplicity and sometimes better performance.
We've also learned:
- How to implement both patterns correctly
- When to choose each approach based on your specific requirements
- How to combine both approaches in hybrid forms
- Performance optimization techniques for complex forms
- Real-world examples and patterns for common use cases
Remember that neither approach is inherently "better" than the other - they are tools with different strengths. The React team generally recommends using controlled components where possible, but uncontrolled components remain useful in many scenarios.
By understanding both approaches, you can make informed decisions about how to implement forms in your React applications, balancing factors like complexity, performance, and functionality.