Understanding Forms in React
Forms are essential for user interaction in web applications. In React, form elements work differently from other DOM elements because they naturally maintain some internal state. React provides two approaches to handling forms: controlled and uncontrolled components.
Core Concept: A controlled component is a form element whose value is controlled by React state. React becomes the "single source of truth" for the form data, making it predictable and easy to manage.
Controlled vs Uncontrolled Components
Understanding the difference between controlled and uncontrolled components is crucial:
// UNCONTROLLED: DOM manages the value
function UncontrolledForm() {
const inputRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
// Access value from DOM directly
console.log(inputRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<input type="text" ref={inputRef} />
<button type="submit">Submit</button>
</form>
);
}
// CONTROLLED: React state manages the value
function ControlledForm() {
const [value, setValue] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
// Value is always in sync with state
console.log(value);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<button type="submit">Submit</button>
</form>
);
}
Best Practice: Use controlled components in most cases. They give you full control over form data, enable validation, conditional rendering, and make debugging easier. Use uncontrolled components only when integrating with non-React code or when performance is critical.
Text Input - Controlled Component
The most common form element is the text input. Here's how to make it controlled:
import { useState } from 'react';
function NameForm() {
const [name, setName] = useState('');
const handleSubmit = (event) => {
event.preventDefault();
alert(`Submitted name: ${name}`);
};
const handleChange = (event) => {
// Update state on every keystroke
setName(event.target.value);
};
return (
<form onSubmit={handleSubmit}>
<label>
Name:
<input
type="text"
value={name}
onChange={handleChange}
placeholder="Enter your name"
/>
</label>
<p>Current value: {name}</p>
<button type="submit">Submit</button>
</form>
);
}
Textarea Element
In React, a <textarea> uses a value attribute instead of children, making it consistent with other input elements:
function EssayForm() {
const [essay, setEssay] = useState('');
const handleSubmit = (event) => {
event.preventDefault();
alert(`Essay submitted with ${essay.length} characters`);
};
return (
<form onSubmit={handleSubmit}>
<label>
Essay:
<textarea
value={essay}
onChange={(e) => setEssay(e.target.value)}
placeholder="Write your essay here..."
rows={5}
/>
</label>
<p>Character count: {essay.length}</p>
<button type="submit">Submit</button>
</form>
);
}
Select Element (Dropdown)
The <select> element also uses a value attribute to control which option is selected:
function FlavorForm() {
const [flavor, setFlavor] = useState('coconut');
const handleSubmit = (event) => {
event.preventDefault();
alert(`Your favorite flavor is: ${flavor}`);
};
return (
<form onSubmit={handleSubmit}>
<label>
Pick your favorite flavor:
<select value={flavor} onChange={(e) => setFlavor(e.target.value)}>
<option value="grapefruit">Grapefruit</option>
<option value="lime">Lime</option>
<option value="coconut">Coconut</option>
<option value="mango">Mango</option>
</select>
</label>
<button type="submit">Submit</button>
</form>
);
}
Checkbox and Radio Inputs
Checkboxes and radio buttons use the checked attribute instead of value:
function PreferencesForm() {
const [preferences, setPreferences] = useState({
newsletter: false,
notifications: true,
theme: 'light'
});
const handleCheckboxChange = (event) => {
const { name, checked } = event.target;
setPreferences(prev => ({
...prev,
[name]: checked
}));
};
const handleRadioChange = (event) => {
setPreferences(prev => ({
...prev,
theme: event.target.value
}));
};
return (
<form>
{/* Checkboxes */}
<label>
<input
type="checkbox"
name="newsletter"
checked={preferences.newsletter}
onChange={handleCheckboxChange}
/>
Subscribe to newsletter
</label>
<br />
<label>
<input
type="checkbox"
name="notifications"
checked={preferences.notifications}
onChange={handleCheckboxChange}
/>
Enable notifications
</label>
<br />
{/* Radio buttons */}
<p>Theme:</p>
<label>
<input
type="radio"
value="light"
checked={preferences.theme === 'light'}
onChange={handleRadioChange}
/>
Light
</label>
<label>
<input
type="radio"
value="dark"
checked={preferences.theme === 'dark'}
onChange={handleRadioChange}
/>
Dark
</label>
<p>Current preferences: {JSON.stringify(preferences, null, 2)}</p>
</form>
);
}
Common Mistake: Forgetting to bind value to state makes the input uncontrolled. Always use value={state} or checked={state} along with onChange handler to keep the input controlled.
Handling Multiple Inputs
When dealing with multiple inputs, you can use a single handler function with computed property names:
function RegistrationForm() {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
age: '',
country: 'usa'
});
// Single handler for all inputs
const handleChange = (event) => {
const { name, value, type, checked } = event.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
const handleSubmit = (event) => {
event.preventDefault();
console.log('Form submitted:', formData);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
placeholder="Username"
/>
<br />
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
/>
<br />
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="Password"
/>
<br />
<input
type="number"
name="age"
value={formData.age}
onChange={handleChange}
placeholder="Age"
/>
<br />
<select name="country" value={formData.country} onChange={handleChange}>
<option value="usa">USA</option>
<option value="uk">UK</option>
<option value="canada">Canada</option>
</select>
<br />
<button type="submit">Register</button>
</form>
);
}
Form Validation
Controlled components make validation straightforward. You can validate on every change or on submit:
function LoginForm() {
const [formData, setFormData] = useState({
email: '',
password: ''
});
const [errors, setErrors] = useState({});
const validateField = (name, value) => {
switch(name) {
case 'email':
if (!value) return 'Email is required';
if (!/\S+@\S+\.\S+/.test(value)) return 'Email is invalid';
return '';
case 'password':
if (!value) return 'Password is required';
if (value.length < 6) return 'Password must be at least 6 characters';
return '';
default:
return '';
}
};
const handleChange = (event) => {
const { name, value } = event.target;
// Update form data
setFormData(prev => ({
...prev,
[name]: value
}));
// Validate on change
const error = validateField(name, value);
setErrors(prev => ({
...prev,
[name]: error
}));
};
const handleSubmit = (event) => {
event.preventDefault();
// Validate all fields
const newErrors = {
email: validateField('email', formData.email),
password: validateField('password', formData.password)
};
setErrors(newErrors);
// Check if there are any errors
const hasErrors = Object.values(newErrors).some(error => error);
if (!hasErrors) {
console.log('Form is valid, submitting:', formData);
// Submit form
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
/>
{errors.email && (
<p style={{ color: 'red' }}>{errors.email}</p>
)}
</div>
<div>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="Password"
/>
{errors.password && (
<p style={{ color: 'red' }}>{errors.password}</p>
)}
</div>
<button type="submit">Login</button>
<form>
);
}
Validation Strategy: For better UX, validate on blur (when user leaves the field) rather than on every keystroke. Validate all fields on submit to catch any missed errors.
File Input
File inputs are always uncontrolled in React because their value can only be set by the user, not programmatically:
function FileUploadForm() {
const [selectedFile, setSelectedFile] = useState(null);
const fileInputRef = useRef(null);
const handleFileChange = (event) => {
const file = event.target.files[0];
if (file) {
setSelectedFile({
name: file.name,
size: file.size,
type: file.type
});
}
};
const handleSubmit = (event) => {
event.preventDefault();
if (selectedFile) {
console.log('Uploading file:', selectedFile);
// Perform file upload
}
};
const handleClear = () => {
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*,.pdf"
/>
{selectedFile && (
<div>
<p>Selected: {selectedFile.name}</p>
<p>Size: {(selectedFile.size / 1024).toFixed(2)} KB</p>
<p>Type: {selectedFile.type}</p>
<button type="button" onClick={handleClear}>Clear</button>
</div>
)}
<button type="submit" disabled={!selectedFile}>
Upload
</button>
</form>
);
}
Complex Form Example
Here's a comprehensive example combining multiple form elements with validation:
function CompleteRegistrationForm() {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
age: '',
gender: '',
country: 'usa',
bio: '',
agreeToTerms: false
});
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
const handleBlur = (e) => {
const { name } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
validateField(name, formData[name]);
};
const validateField = (name, value) => {
let error = '';
switch(name) {
case 'firstName':
case 'lastName':
if (!value.trim()) error = `${name} is required`;
break;
case 'email':
if (!value) error = 'Email is required';
else if (!/\S+@\S+\.\S+/.test(value)) error = 'Invalid email';
break;
case 'password':
if (!value) error = 'Password is required';
else if (value.length < 8) error = 'Min 8 characters';
break;
case 'confirmPassword':
if (value !== formData.password) error = 'Passwords must match';
break;
case 'age':
if (value && (value < 18 || value > 100)) error = 'Age must be 18-100';
break;
}
setErrors(prev => ({ ...prev, [name]: error }));
return error;
};
const handleSubmit = (e) => {
e.preventDefault();
// Validate all fields
const allErrors = {};
Object.keys(formData).forEach(key => {
const error = validateField(key, formData[key]);
if (error) allErrors[key] = error;
});
if (!formData.agreeToTerms) {
allErrors.agreeToTerms = 'You must agree to terms';
}
setErrors(allErrors);
if (Object.keys(allErrors).length === 0) {
console.log('Form submitted successfully:', formData);
// Submit to server
}
};
return (
<form onSubmit={handleSubmit}>
{/* First Name */}
<input
name="firstName"
value={formData.firstName}
onChange={handleChange}
onBlur={handleBlur}
placeholder="First Name"
/>
{touched.firstName && errors.firstName && (
<span className="error">{errors.firstName}</span>
)}
{/* Add other fields similarly... */}
{/* Terms checkbox */}
<label>
<input
type="checkbox"
name="agreeToTerms"
checked={formData.agreeToTerms}
onChange={handleChange}
/>
I agree to terms and conditions
</label>
{errors.agreeToTerms && (
<span className="error">{errors.agreeToTerms}</span>
)}
<button type="submit">Register</button>
</form>
);
}
Form Libraries: For complex forms with advanced validation, consider using form libraries like Formik, React Hook Form, or React Final Form. They provide built-in validation, error handling, and reduce boilerplate code.
Exercise 1: Contact Form with Validation
Create a contact form with the following requirements:
- Fields: Name (required), Email (required, valid format), Phone (optional, 10 digits), Message (required, min 10 chars)
- Show error messages only after user leaves the field (onBlur)
- Disable submit button if form has errors
- Show character count for message field
- After successful submission, show success message and clear form
- Add a reset button to clear all fields
Hint: Use touched state to track which fields the user has interacted with.
Exercise 2: Dynamic Survey Form
Build a survey form that changes based on user responses:
- Start with: Name, Age, Employment status (dropdown: Employed/Student/Unemployed)
- If "Employed": show Company name and Position fields
- If "Student": show School name and Major fields
- All users: Show checkbox list of interests (Sports, Music, Reading, Gaming, Travel)
- Based on selected interests, show follow-up questions (e.g., if Music: favorite genre)
- Calculate and display survey completion percentage
- Summary page showing all answers before submit
Bonus: Add navigation between sections with validation on each step.
Exercise 3: E-commerce Checkout Form
Create a multi-step checkout form:
- Step 1: Shipping info (Full name, Address, City, State, ZIP, Country)
- Step 2: Payment method (radio: Credit card/PayPal/Bank transfer)
- If Credit card: show card number, expiry, CVV fields
- Step 3: Review order with all details and total price
- Validate each step before allowing next step
- Add "Back" and "Next" buttons
- Save form data to localStorage to persist between refreshes
- Add progress indicator showing current step
Hint: Use multiple state objects, one for each step, and a step counter.