Form State Management and Validation
1. Controlled Input Components and State Binding
| Input Type | State Binding | Update Handler | Key Props |
|---|---|---|---|
| Text input | value={state} |
onChange={e => setState(e.target.value)} |
value, onChange, name, id |
| Textarea | value={state} |
onChange={e => setState(e.target.value)} |
value, onChange, rows, cols |
| Checkbox | checked={state} |
onChange={e => setState(e.target.checked)} |
checked, onChange, type="checkbox" |
| Radio button | checked={state === value} |
onChange={() => setState(value)} |
checked, onChange, value, name |
| Select dropdown | value={state} |
onChange={e => setState(e.target.value)} |
value, onChange, multiple (optional) |
| File input | N/A (always uncontrolled) | onChange={e => setFile(e.target.files[0])} |
onChange, type="file", accept |
Example: Controlled form with multiple input types
function RegistrationForm() {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
age: '',
newsletter: false,
gender: '',
country: 'US'
});
// Generic handler for text inputs
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
const handleSubmit = (e) => {
e.preventDefault();
console.log('Form data:', formData);
};
return (
<form onSubmit={handleSubmit}>
{/* Text input */}
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
placeholder="Username"
/>
{/* Email input */}
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
/>
{/* Password input */}
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
placeholder="Password"
/>
{/* Number input */}
<input
type="number"
name="age"
value={formData.age}
onChange={handleChange}
placeholder="Age"
/>
{/* Checkbox */}
<label>
<input
type="checkbox"
name="newsletter"
checked={formData.newsletter}
onChange={handleChange}
/>
Subscribe to newsletter
</label>
{/* Radio buttons */}
<div>
<label>
<input
type="radio"
name="gender"
value="male"
checked={formData.gender === 'male'}
onChange={handleChange}
/>
Male
</label>
<label>
<input
type="radio"
name="gender"
value="female"
checked={formData.gender === 'female'}
onChange={handleChange}
/>
Female
</label>
</div>
{/* Select dropdown */}
<select name="country" value={formData.country} onChange={handleChange}>
<option value="US">United States</option>
<option value="UK">United Kingdom</option>
<option value="CA">Canada</option>
</select>
<button type="submit">Register</button>
</form>
);
}
Controlled vs Uncontrolled Inputs:
| Aspect | Controlled | Uncontrolled |
|---|---|---|
| Value source | React state | DOM (ref) |
| Updates | onChange updates state | DOM manages itself |
| Validation | Real-time validation | Validation on submit |
| Use case | Most forms, validation required | Simple forms, file uploads |
2. Uncontrolled Components with useRef Patterns
| Pattern | Implementation | Access Method | Use Case |
|---|---|---|---|
| Single input ref | const ref = useRef(null) |
ref.current.value |
Simple form with few fields |
| Multiple refs | Separate ref for each input | ref1.current.value, ref2.current.value |
Multiple inputs, accessed on submit |
| Ref object | const refs = useRef({}) |
refs.current[name].value |
Dynamic number of inputs |
| Default value | defaultValue={initial} |
Set initial value without state | Uncontrolled input with initial value |
Pattern 1: Simple uncontrolled form
function UncontrolledForm() {
const nameRef = useRef(null);
const emailRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const formData = {
name: nameRef.current.value,
email: emailRef.current.value
};
console.log('Form data:', formData);
// Reset form
nameRef.current.value = '';
emailRef.current.value = '';
};
return (
<form onSubmit={handleSubmit}>
<input
ref={nameRef}
type="text"
defaultValue=""
placeholder="Name"
/>
<input
ref={emailRef}
type="email"
defaultValue=""
placeholder="Email"
/>
<button type="submit">Submit</button>
</form>
);
}
Pattern 2: File upload (always uncontrolled)
function FileUpload() {
const [preview, setPreview] = useState(null);
const fileRef = useRef(null);
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result);
};
reader.readAsDataURL(file);
}
};
const handleSubmit = (e) => {
e.preventDefault();
const file = fileRef.current.files[0];
const formData = new FormData();
formData.append('file', file);
// Upload file
fetch('/api/upload', {
method: 'POST',
body: formData
});
};
return (
<form onSubmit={handleSubmit}>
<input
ref={fileRef}
type="file"
onChange={handleFileChange}
accept="image/*"
/>
{preview && <img src={preview} />}
<button type="submit">Upload</button>
</form>
);
}
3. Form Validation State and Error Handling
| Validation Type | Timing | State Structure | User Experience |
|---|---|---|---|
| On submit | Form submission | {fieldName: 'error message'} |
Show all errors at once |
| On blur | Field loses focus | {fieldName: {error, touched}} |
Validate after user finishes field |
| On change (real-time) | Every keystroke | {fieldName: {error, dirty}} |
Immediate feedback (can be annoying) |
| Hybrid (touched + dirty) | Blur first, then onChange | {fieldName: {error, touched, dirty}} |
Best UX - validate on blur, update on change |
Example: Comprehensive form validation
function ValidatedForm() {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
confirmPassword: ''
});
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
// Validation rules
const validate = (name, value) => {
switch (name) {
case 'username':
if (!value) return 'Username is required';
if (value.length < 3) return 'Username must be at least 3 characters';
if (!/^[a-zA-Z0-9_]+$/.test(value)) return 'Only letters, numbers, underscore';
return '';
case 'email':
if (!value) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Invalid email format';
return '';
case 'password':
if (!value) return 'Password is required';
if (value.length < 8) return 'Password must be at least 8 characters';
if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) {
return 'Must contain uppercase, lowercase, and number';
}
return '';
case 'confirmPassword':
if (!value) return 'Please confirm password';
if (value !== formData.password) return 'Passwords do not match';
return '';
default:
return '';
}
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Validate on change if field was touched
if (touched[name]) {
const error = validate(name, value);
setErrors(prev => ({ ...prev, [name]: error }));
}
};
const handleBlur = (e) => {
const { name, value } = e.target;
// Mark field as touched
setTouched(prev => ({ ...prev, [name]: true }));
// Validate on blur
const error = validate(name, value);
setErrors(prev => ({ ...prev, [name]: error }));
};
const handleSubmit = (e) => {
e.preventDefault();
// Validate all fields
const newErrors = {};
Object.keys(formData).forEach(name => {
const error = validate(name, formData[name]);
if (error) newErrors[name] = error;
});
setErrors(newErrors);
setTouched(Object.keys(formData).reduce((acc, key) => {
acc[key] = true;
return acc;
}, {}));
// If no errors, submit
if (Object.keys(newErrors).length === 0) {
console.log('Form submitted:', formData);
}
};
const getFieldError = (name) => touched[name] && errors[name];
return (
<form onSubmit={handleSubmit}>
<div>
<input
name="username"
value={formData.username}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Username"
className={getFieldError('username') ? 'error' : ''}
/>
{getFieldError('username') && (
<span className="error-message">{errors.username}</span>
)}
</div>
<div>
<input
name="email"
type="email"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Email"
className={getFieldError('email') ? 'error' : ''}
/>
{getFieldError('email') && (
<span className="error-message">{errors.email}</span>
)}
</div>
<div>
<input
name="password"
type="password"
value={formData.password}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Password"
className={getFieldError('password') ? 'error' : ''}
/>
{getFieldError('password') && (
<span className="error-message">{errors.password}</span>
)}
</div>
<div>
<input
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleChange}
onBlur={handleBlur}
placeholder="Confirm Password"
className={getFieldError('confirmPassword') ? 'error' : ''}
/>
{getFieldError('confirmPassword') && (
<span className="error-message">{errors.confirmPassword}</span>
)}
</div>
<button type="submit">Register</button>
</form>
);
}
Validation Best Practices:
- Use on-blur for initial validation (better UX than real-time)
- Switch to on-change after field is touched (immediate feedback for corrections)
- Show errors only for touched fields (don't overwhelm on page load)
- Validate all fields on submit (catch any missed validations)
- Consider async validation for username/email availability checks
- Use schema validation libraries (Yup, Zod) for complex forms
4. Multi-step Form State Management
| Pattern | State Structure | Navigation | Use Case |
|---|---|---|---|
| Step index | {step: 0, data: {}} |
Increment/decrement step number | Linear wizard flow |
| Step array | {steps: ['personal', 'address'], current: 0} |
Move through array of step names | Named steps, flexible order |
| State machine | {state: 'personal', data: {}} |
State transitions with validation | Complex conditional flows |
| Validated steps | {step: 0, completed: [0], data: {}} |
Track which steps are valid | Allow non-linear navigation |
Example: Multi-step form with validation
function MultiStepForm() {
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState({
// Step 1: Personal Info
firstName: '',
lastName: '',
email: '',
// Step 2: Address
street: '',
city: '',
zipCode: '',
// Step 3: Payment
cardNumber: '',
expiryDate: '',
cvv: ''
});
const [errors, setErrors] = useState({});
const steps = [
{ title: 'Personal Info', fields: ['firstName', 'lastName', 'email'] },
{ title: 'Address', fields: ['street', 'city', 'zipCode'] },
{ title: 'Payment', fields: ['cardNumber', 'expiryDate', 'cvv'] }
];
const validateStep = (stepIndex) => {
const stepFields = steps[stepIndex].fields;
const stepErrors = {};
stepFields.forEach(field => {
if (!formData[field]) {
stepErrors[field] = 'This field is required';
}
// Add specific validations
if (field === 'email' && formData[field]) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData[field])) {
stepErrors[field] = 'Invalid email';
}
}
if (field === 'zipCode' && formData[field]) {
if (!/^\d{5}$/.test(formData[field])) {
stepErrors[field] = 'Invalid zip code';
}
}
});
setErrors(stepErrors);
return Object.keys(stepErrors).length === 0;
};
const handleNext = () => {
if (validateStep(currentStep)) {
setCurrentStep(prev => Math.min(prev + 1, steps.length - 1));
}
};
const handleBack = () => {
setCurrentStep(prev => Math.max(prev - 1, 0));
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error for this field
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
};
const handleSubmit = (e) => {
e.preventDefault();
if (validateStep(currentStep)) {
console.log('Form submitted:', formData);
// Submit to API
}
};
const renderStep = () => {
const currentFields = steps[currentStep].fields;
return (
<div>
{currentFields.map(field => (
<div key={field}>
<input
name={field}
value={formData[field]}
onChange={handleChange}
placeholder={field}
/>
{errors[field] && <span className="error">{errors[field]}</span>}
</div>
))}
</div>
);
};
return (
<form onSubmit={handleSubmit}>
{/* Progress indicator */}
<div className="progress">
Step {currentStep + 1} of {steps.length}: {steps[currentStep].title}
</div>
{/* Step content */}
{renderStep()}
{/* Navigation */}
<div className="buttons">
{currentStep > 0 && (
<button type="button" onClick={handleBack}>
Back
</button>
)}
{currentStep < steps.length - 1 ? (
<button type="button" onClick={handleNext}>
Next
</button>
) : (
<button type="submit">Submit</button>
)}
</div>
</form>
);
}
Multi-step Form Tips:
- Store all form data in single state object (easier to manage)
- Validate each step before allowing navigation to next
- Show progress indicator (step X of Y)
- Allow back navigation without validation
- Consider saving draft to localStorage on step change
- Use URL params to enable direct linking to specific steps
5. Form Reset and Clear Operations
| Operation | Implementation | When to Use | Side Effects |
|---|---|---|---|
| Reset to initial | setFormData(initialState) |
After successful submit, cancel | Clears all fields to original values |
| Clear all fields | setFormData({}) or empty strings |
New entry, clear button | Sets all fields to empty/default |
| Reset single field | setFormData(prev => ({...prev, field: ''})) |
Clear individual input | Resets one field, keeps others |
| Form.reset() | formRef.current.reset() |
Uncontrolled forms only | Browser native reset (doesn't update state) |
Example: Form reset patterns
function FormWithReset() {
const initialFormData = {
name: '',
email: '',
message: ''
};
const [formData, setFormData] = useState(initialFormData);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
await fetch('/api/contact', {
method: 'POST',
body: JSON.stringify(formData)
});
// ✅ Reset to initial state after successful submit
resetForm();
alert('Form submitted successfully!');
} catch (error) {
setErrors({ submit: 'Failed to submit form' });
}
};
// Complete reset - clears data, errors, and touched state
const resetForm = () => {
setFormData(initialFormData);
setErrors({});
setTouched({});
};
// Clear single field
const clearField = (fieldName) => {
setFormData(prev => ({ ...prev, [fieldName]: '' }));
setErrors(prev => {
const newErrors = { ...prev };
delete newErrors[fieldName];
return newErrors;
});
setTouched(prev => {
const newTouched = { ...prev };
delete newTouched[fieldName];
return newTouched;
});
};
return (
<form onSubmit={handleSubmit}>
<div>
<input
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Name"
/>
{formData.name && (
<button type="button" onClick={() => clearField('name')}>
Clear
</button>
)}
</div>
<div>
<input
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
/>
</div>
<div>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Message"
/>
</div>
<div className="buttons">
<button type="submit">Submit</button>
<button type="button" onClick={resetForm}>
Reset Form
</button>
</div>
</form>
);
}
Reset Gotchas:
form.reset()only works for uncontrolled forms - doesn't update React state- Remember to clear validation errors and touched state when resetting
- Consider showing confirmation dialog before clearing filled form
- Don't reset form on failed submission (preserve user data)
6. Dynamic Form Fields and Array State Management
| Pattern | State Structure | Operations | Use Case |
|---|---|---|---|
| Array of objects | [{id, field1, field2}] |
Add, remove, update by index/id | Repeated field groups (contacts, items) |
| Nested object with IDs | {[id]: {field1, field2}} |
Add/remove by key, update by id | Normalized structure, easier lookups |
| Array with unique keys | [{key: uuid(), ...fields}] |
Use UUID for stable React keys | Prevent key issues with add/remove |
Example: Dynamic fields - add/remove items
import { useState } from 'react';
function DynamicForm() {
const [contacts, setContacts] = useState([
{ id: Date.now(), name: '', email: '', phone: '' }
]);
const addContact = () => {
setContacts(prev => [
...prev,
{ id: Date.now(), name: '', email: '', phone: '' }
]);
};
const removeContact = (id) => {
setContacts(prev => prev.filter(contact => contact.id !== id));
};
const updateContact = (id, field, value) => {
setContacts(prev =>
prev.map(contact =>
contact.id === id
? { ...contact, [field]: value }
: contact
)
);
};
const handleSubmit = (e) => {
e.preventDefault();
console.log('Contacts:', contacts);
};
return (
<form onSubmit={handleSubmit}>
{contacts.map((contact, index) => (
<div key={contact.id} className="contact-group">
<h4>Contact {index + 1}</h4>
<input
type="text"
value={contact.name}
onChange={(e) => updateContact(contact.id, 'name', e.target.value)}
placeholder="Name"
/>
<input
type="email"
value={contact.email}
onChange={(e) => updateContact(contact.id, 'email', e.target.value)}
placeholder="Email"
/>
<input
type="tel"
value={contact.phone}
onChange={(e) => updateContact(contact.id, 'phone', e.target.value)}
placeholder="Phone"
/>
{contacts.length > 1 && (
<button
type="button"
onClick={() => removeContact(contact.id)}
>
Remove
</button>
)}
</div>
))}
<button type="button" onClick={addContact}>
Add Another Contact
</button>
<button type="submit">Submit All Contacts</button>
</form>
);
}
Example: Nested dynamic fields with validation
function OrderForm() {
const [order, setOrder] = useState({
customerName: '',
items: [
{ id: 1, product: '', quantity: 1, price: 0 }
]
});
const addItem = () => {
setOrder(prev => ({
...prev,
items: [
...prev.items,
{ id: Date.now(), product: '', quantity: 1, price: 0 }
]
}));
};
const removeItem = (id) => {
setOrder(prev => ({
...prev,
items: prev.items.filter(item => item.id !== id)
}));
};
const updateItem = (id, field, value) => {
setOrder(prev => ({
...prev,
items: prev.items.map(item =>
item.id === id ? { ...item, [field]: value } : item
)
}));
};
// Calculate total
const total = order.items.reduce(
(sum, item) => sum + (item.quantity * item.price),
0
);
const handleSubmit = (e) => {
e.preventDefault();
// Validate
if (!order.customerName) {
alert('Customer name required');
return;
}
if (order.items.some(item => !item.product)) {
alert('All items must have a product');
return;
}
console.log('Order:', order, 'Total:', total);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={order.customerName}
onChange={(e) => setOrder(prev => ({
...prev,
customerName: e.target.value
}))}
placeholder="Customer Name"
/>
<h3>Items</h3>
{order.items.map((item, index) => (
<div key={item.id}>
<input
type="text"
value={item.product}
onChange={(e) => updateItem(item.id, 'product', e.target.value)}
placeholder="Product"
/>
<input
type="number"
value={item.quantity}
onChange={(e) => updateItem(item.id, 'quantity', parseInt(e.target.value))}
min="1"
/>
<input
type="number"
value={item.price}
onChange={(e) => updateItem(item.id, 'price', parseFloat(e.target.value))}
step="0.01"
min="0"
/>
<span>Subtotal: ${(item.quantity * item.price).toFixed(2)}</span>
{order.items.length > 1 && (
<button type="button" onClick={() => removeItem(item.id)}>
Remove
</button>
)}
</div>
))}
<button type="button" onClick={addItem}>Add Item</button>
<div><strong>Total: ${total.toFixed(2)}</strong></div>
<button type="submit">Submit Order</button>
</form>
);
}
Dynamic Fields Best Practices:
- Use unique IDs (timestamp or UUID) for array items, not index
- Provide "Remove" button only when more than minimum items exist
- Consider minimum/maximum limits for dynamic fields
- Validate each item individually and show errors per item
- Use functional updates when modifying nested arrays
- Consider using libraries like react-hook-form for complex dynamic forms
Form State Management Summary:
- Controlled inputs - Use value + onChange for real-time validation and state sync
- Uncontrolled with refs - Simple forms, file uploads, or third-party integration
- Validation timing - Blur for initial, change for corrections (hybrid approach)
- Error state - Track errors + touched state, show only for touched fields
- Multi-step forms - Single state object, validate per step, show progress
- Form reset - Clear data, errors, and touched state together
- Dynamic fields - Use unique IDs (not index), functional updates for nested state
- Generic handlers - Single onChange handler for multiple inputs using name attribute
- Form libraries - Consider react-hook-form or Formik for complex forms