📋 Forms in React
Forms are everywhere in web applications - login screens, registration pages, search bars, settings panels, checkout flows, and more. But handling forms in React is different from traditional HTML forms. React gives you complete control over form data through state, making forms predictable, testable, and powerful. In this lesson, you'll master controlled components, handle complex form scenarios, implement validation, and build type-safe forms that are a joy to use. Let's make forms great again! 🚀
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Understand controlled vs uncontrolled components
- Build controlled form inputs with useState
- Handle multiple form inputs efficiently
- Implement client-side form validation
- Type form data safely with TypeScript
- Handle form submission properly
- Work with different input types (text, checkbox, radio, select)
- Build reusable form components
- Display validation errors effectively
Estimated Time: 75-90 minutes
Project: Build a multi-step registration form with validation
📑 In This Lesson
🎛️ Controlled vs Uncontrolled Components
Before we dive into building forms, you need to understand a fundamental concept in React forms: controlled vs uncontrolled components.
📖 Key Definitions
Controlled Component: A form element whose value is controlled by React state. React is the "single source of truth."
Uncontrolled Component: A form element that maintains its own internal state. The DOM is the source of truth.
Traditional HTML Forms (Uncontrolled)
How HTML Forms Normally Work
<!-- Traditional HTML form -->
<form>
<input type="text" name="username" />
<button type="submit">Submit</button>
</form>
<script>
// You only get the value when form submits
form.addEventListener('submit', (e) => {
const username = e.target.username.value;
console.log(username);
});
</script>
In traditional HTML:
- The DOM stores the input value
- You query the DOM when you need the value
- You can't easily validate or transform as user types
React Controlled Components
The React Way
const ControlledInput: React.FC = () => {
// React state is the source of truth
const [username, setUsername] = useState('');
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={username} // Value comes from state
onChange={(e) => setUsername(e.target.value)} // Update state
/>
<button type="submit">Submit</button>
</form>
);
};
In controlled components:
- React state stores the value
- Input displays what's in state
- onChange updates state, triggering re-render
- You always know the current value
The Data Flow
sequenceDiagram
participant User
participant Input
participant State
participant React
User->>Input: Types "A"
Input->>React: onChange event
React->>State: setUsername("A")
State->>React: State updated
React->>Input: Re-render with value="A"
Input->>User: Display "A"
Note over State,React: State is single source of truth
Comparison Table
| Aspect | Controlled | Uncontrolled |
|---|---|---|
| Source of truth | React state | DOM |
| Current value access | Always available in state | Need to query DOM |
| Validation | Easy - validate on every change | Harder - usually on submit |
| Transform input | Easy - modify state | Difficult |
| Complexity | More code (state + handler) | Less code |
| React way? | ✅ Recommended | ⚠️ Rarely used |
✅ When to Use Each
Use Controlled Components (99% of the time):
- Forms with validation
- When you need the current value
- Dynamic forms that show/hide based on values
- When transforming user input (formatting, uppercase, etc.)
Use Uncontrolled Components (rarely):
- File inputs (must be uncontrolled)
- Integrating with non-React code
- Very simple forms where you only need value on submit
Quick Example: Both Approaches
Controlled (Recommended)
const ControlledForm: React.FC = () => {
const [email, setEmail] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log('Email:', email); // Always have the value!
};
return (
<form onSubmit={handleSubmit}>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<p>Current: {email}</p> {/* Can display anytime! */}
</form>
);
};
Uncontrolled (Rare)
const UncontrolledForm: React.FC = () => {
const emailRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log('Email:', emailRef.current?.value); // Query DOM
};
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} />
{/* Can't easily show current value */}
</form>
);
};
💡 The React Philosophy
React prefers controlled components because they fit the React mental model: state drives the UI. When state changes, UI updates. With controlled components, your form data is just another piece of state - predictable, testable, and React-like.
From now on, we'll focus exclusively on controlled components!
📝 Basic Controlled Inputs
Let's master the fundamentals of controlled inputs, starting simple and building up complexity.
Single Text Input
The Simplest Controlled Input
import React, { useState } from 'react';
const SimpleInput: React.FC = () => {
const [name, setName] = useState('');
return (
<div>
<label htmlFor="name">Name:</label>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<p>Hello, {name || 'stranger'}!</p>
</div>
);
};
Anatomy of a Controlled Input
Breaking It Down
<input
id="name" // For label association
type="text" // Input type
value={name} // Controlled by state
onChange={(e) => setName(e.target.value)} // Update state on change
placeholder="Enter your name" // Optional placeholder
disabled={isLoading} // Optional disabled state
/>
Key points:
value={name}- Input always shows what's in stateonChange- Updates state when user typese.target.value- Gets the new input value- Without
onChange, input would be read-only!
Email Input with Validation
Real-Time Validation
const EmailInput: React.FC = () => {
const [email, setEmail] = useState('');
// Validate as user types
const isValidEmail = email.includes('@') && email.includes('.');
const showError = email.length > 0 && !isValidEmail;
return (
<div>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{
borderColor: showError ? 'red' : '#ddd'
}}
/>
{showError && (
<p style={{ color: 'red', fontSize: '0.875rem' }}>
Please enter a valid email
</p>
)}
{isValidEmail && (
<p style={{ color: 'green', fontSize: '0.875rem' }}>
✓ Looks good!
</p>
)}
</div>
);
};
Password Input with Strength Indicator
Dynamic Feedback
const PasswordInput: React.FC = () => {
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
// Calculate password strength
const getStrength = (pwd: string): 'weak' | 'medium' | 'strong' => {
if (pwd.length < 6) return 'weak';
if (pwd.length < 10) return 'medium';
return 'strong';
};
const strength = getStrength(password);
const strengthColors = {
weak: '#f44336',
medium: '#ff9800',
strong: '#4CAF50'
};
return (
<div>
<label htmlFor="password">Password:</label>
<div style={{ position: 'relative' }}>
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
style={{ width: '100%', paddingRight: '80px' }}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
style={{
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)'
}}
>
{showPassword ? '🙈' : '👁️'}
</button>
</div>
{password && (
<div style={{ marginTop: '0.5rem' }}>
<div
style={{
height: '4px',
background: '#eee',
borderRadius: '2px',
overflow: 'hidden'
}}
>
<div
style={{
height: '100%',
width: strength === 'weak' ? '33%' : strength === 'medium' ? '66%' : '100%',
background: strengthColors[strength],
transition: 'all 0.3s'
}}
/>
</div>
<p style={{
color: strengthColors[strength],
fontSize: '0.875rem',
marginTop: '0.25rem'
}}>
Strength: {strength}
</p>
</div>
)}
</div>
);
};
💡 Pro Tips for Inputs
- Always use
htmlForon labels (connects to inputid) - Set appropriate
typeattributes (email, password, tel, etc.) - Add
placeholdertext to guide users - Use
autoCompleteto help browsers fill forms - Provide immediate feedback (validation, character count, etc.)
🎛️ Handling Multiple Inputs
Real forms have multiple fields. Let's learn efficient patterns for managing them!
Approach 1: Multiple State Variables
Simple but Verbose
const SimpleForm: React.FC = () => {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState('');
return (
<form>
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
<input value={lastName} onChange={(e) => setLastName(e.target.value)} />
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<input value={age} onChange={(e) => setAge(e.target.value)} />
</form>
);
};
✅ Pros: Simple, explicit
❌ Cons: Repetitive, lots of state variables
Approach 2: Single State Object (Recommended)
Clean and Scalable
interface FormData {
firstName: string;
lastName: string;
email: string;
age: string;
}
const BetterForm: React.FC = () => {
const [formData, setFormData] = useState<FormData>({
firstName: '',
lastName: '',
email: '',
age: ''
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value // Dynamic key!
}));
};
return (
<form>
<input
name="firstName"
value={formData.firstName}
onChange={handleChange}
/>
<input
name="lastName"
value={formData.lastName}
onChange={handleChange}
/>
<input
name="email"
value={formData.email}
onChange={handleChange}
/>
<input
name="age"
value={formData.age}
onChange={handleChange}
/>
</form>
);
};
✅ Pros: One handler, scalable, organized
✅ Use this approach for most forms!
The Power of Dynamic Keys
How [name]: value Works
// When you type in the firstName input:
const name = "firstName";
const value = "John";
// This:
setFormData(prev => ({
...prev,
[name]: value
}));
// Becomes:
{
firstName: "John", // Updated!
lastName: "",
email: "",
age: ""
}
The [name] syntax uses the variable value as the key!
Complete Example: Contact Form
Production-Ready Multi-Input Form
import React, { useState } from 'react';
interface ContactFormData {
name: string;
email: string;
subject: string;
message: string;
}
const ContactForm: React.FC = () => {
const [formData, setFormData] = useState<ContactFormData>({
name: '',
email: '',
subject: '',
message: ''
});
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const { name, value } = e.target;
🎮 Interactive Demo: Form State in Action
Type in the fields below and watch the state update in real-time:
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log('Form submitted:', formData);
// Send to API, etc.
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: '500px', margin: '0 auto' }}>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="name" style={{ display: 'block', marginBottom: '0.5rem' }}>
Name *
</label>
<input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
required
style={{ width: '100%', padding: '0.5rem' }}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email" style={{ display: 'block', marginBottom: '0.5rem' }}>
Email *
</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
required
style={{ width: '100%', padding: '0.5rem' }}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="subject" style={{ display: 'block', marginBottom: '0.5rem' }}>
Subject *
</label>
<input
id="subject"
name="subject"
type="text"
value={formData.subject}
onChange={handleChange}
required
style={{ width: '100%', padding: '0.5rem' }}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="message" style={{ display: 'block', marginBottom: '0.5rem' }}>
Message *
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
required
rows={5}
style={{ width: '100%', padding: '0.5rem', resize: 'vertical' }}
/>
</div>
<button
type="submit"
style={{
width: '100%',
padding: '0.75rem',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
fontSize: '1rem',
cursor: 'pointer'
}}
>
Send Message
</button>
</form>
);
};
export default ContactForm;
✅ Best Practice Pattern
For most forms, use this pattern:
- Single state object with interface
- One generic
handleChangefunction - Use
nameattribute to identify inputs - Use computed property names
[name]
🎨 Different Input Types
Forms aren't just text boxes! Let's handle checkboxes, radio buttons, selects, and more.
Checkbox Inputs
Single Checkbox
const CheckboxExample: React.FC = () => {
const [agreed, setAgreed] = useState(false);
return (
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="checkbox"
checked={agreed} // Use 'checked', not 'value'!
onChange={(e) => setAgreed(e.target.checked)} // Use .checked!
/>
I agree to the terms and conditions
</label>
);
};
Key difference: Use checked and e.target.checked, not value!
Multiple Checkboxes
interface Preferences {
newsletter: boolean;
updates: boolean;
offers: boolean;
}
const MultiCheckbox: React.FC = () => {
const [preferences, setPreferences] = useState<Preferences>({
newsletter: false,
updates: false,
offers: false
});
const handleCheckboxChange = (name: keyof Preferences) => {
setPreferences(prev => ({
...prev,
[name]: !prev[name] // Toggle the boolean
}));
};
return (
<div>
<h3>Email Preferences</h3>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
<input
type="checkbox"
checked={preferences.newsletter}
onChange={() => handleCheckboxChange('newsletter')}
/>
{' '}Weekly Newsletter
</label>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
<input
type="checkbox"
checked={preferences.updates}
onChange={() => handleCheckboxChange('updates')}
/>
{' '}Product Updates
</label>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
<input
type="checkbox"
checked={preferences.offers}
onChange={() => handleCheckboxChange('offers')}
/>
{' '}Special Offers
</label>
</div>
);
};
Radio Button Inputs
Radio Group
type Size = 'small' | 'medium' | 'large';
const RadioExample: React.FC = () => {
const [size, setSize] = useState<Size>('medium');
return (
<div>
<h3>Select Size</h3>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
<input
type="radio"
name="size" // Same name groups them together
value="small"
checked={size === 'small'}
onChange={(e) => setSize(e.target.value as Size)}
/>
{' '}Small
</label>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
<input
type="radio"
name="size"
value="medium"
checked={size === 'medium'}
onChange={(e) => setSize(e.target.value as Size)}
/>
{' '}Medium
</label>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
<input
type="radio"
name="size"
value="large"
checked={size === 'large'}
onChange={(e) => setSize(e.target.value as Size)}
/>
{' '}Large
</label>
<p>Selected: {size}</p>
</div>
);
};
Select Dropdown
Single Select
const SelectExample: React.FC = () => {
const [country, setCountry] = useState('');
return (
<div>
<label htmlFor="country">Country:</label>
<select
id="country"
value={country}
onChange={(e) => setCountry(e.target.value)}
style={{ width: '100%', padding: '0.5rem' }}
>
<option value="">Select a country...</option>
<option value="us">United States</option>
<option value="uk">United Kingdom</option>
<option value="ca">Canada</option>
<option value="au">Australia</option>
</select>
{country && <p>Selected: {country}</p>}
</div>
);
};
Multi-Select
const MultiSelectExample: React.FC = () => {
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const options = e.target.options;
const selected: string[] = [];
for (let i = 0; i < options.length; i++) {
if (options[i].selected) {
selected.push(options[i].value);
}
}
setSelectedSkills(selected);
};
return (
<div>
<label htmlFor="skills">Skills (hold Ctrl/Cmd to select multiple):</label>
<select
id="skills"
multiple
value={selectedSkills}
onChange={handleSelectChange}
style={{ width: '100%', height: '150px' }}
>
<option value="javascript">JavaScript</option>
<option value="typescript">TypeScript</option>
<option value="react">React</option>
<option value="node">Node.js</option>
<option value="python">Python</option>
</select>
<p>Selected: {selectedSkills.join(', ') || 'None'}</p>
</div>
);
};
Complete Form with All Input Types
Registration Form
interface RegistrationData {
username: string;
email: string;
password: string;
age: string;
gender: 'male' | 'female' | 'other' | '';
country: string;
newsletter: boolean;
terms: boolean;
}
const RegistrationForm: React.FC = () => {
const [formData, setFormData] = useState<RegistrationData>({
username: '',
email: '',
password: '',
age: '',
gender: '',
country: '',
newsletter: false,
terms: false
});
// Handle text inputs
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
// Handle checkboxes
const handleCheckboxChange = (name: keyof RegistrationData) => {
setFormData(prev => ({ ...prev, [name]: !prev[name] }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log('Registration data:', formData);
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: '500px' }}>
{/* Text input */}
<div style={{ marginBottom: '1rem' }}>
<label>Username:</label>
<input
name="username"
value={formData.username}
onChange={handleTextChange}
/>
</div>
{/* Radio buttons */}
<div style={{ marginBottom: '1rem' }}>
<label>Gender:</label>
<label>
<input
type="radio"
name="gender"
value="male"
checked={formData.gender === 'male'}
onChange={handleTextChange}
/> Male
</label>
<label>
<input
type="radio"
name="gender"
value="female"
checked={formData.gender === 'female'}
onChange={handleTextChange}
/> Female
</label>
</div>
{/* Select dropdown */}
<div style={{ marginBottom: '1rem' }}>
<label>Country:</label>
<select name="country" value={formData.country} onChange={handleTextChange}>
<option value="">Select...</option>
<option value="us">USA</option>
<option value="uk">UK</option>
</select>
</div>
{/* Checkboxes */}
<div style={{ marginBottom: '1rem' }}>
<label>
<input
type="checkbox"
checked={formData.newsletter}
onChange={() => handleCheckboxChange('newsletter')}
/>
Subscribe to newsletter
</label>
</div>
<button type="submit">Register</button>
🎨 Input Types Visual Reference
</form>
);
};
Input Types Quick Reference
| Input Type | Value Attribute | Get Value From | Example |
|---|---|---|---|
| text, email, password | value |
e.target.value |
Text inputs |
| checkbox | checked |
e.target.checked |
Boolean toggles |
| radio | checked |
e.target.value |
Single choice from group |
| select | value |
e.target.value |
Dropdown selection |
| textarea | value |
e.target.value |
Multi-line text |
| number | value |
e.target.value |
Numeric input |
✅ Form Validation
Good forms validate user input and provide helpful feedback. Let's implement robust validation!
Validation Strategies
| Strategy | When It Runs | Best For |
|---|---|---|
| On Change | Every keystroke | Real-time feedback (email format, etc.) |
| On Blur | When field loses focus | After user finishes typing |
| On Submit | When form submitted | Final validation check |
| Combination | Multiple triggers | Best user experience |
Basic Validation Pattern
Validate on Blur and Submit
interface FormData {
email: string;
password: string;
}
interface FormErrors {
email?: string;
password?: string;
}
const ValidatedForm: React.FC = () => {
const [formData, setFormData] = useState<FormData>({
email: '',
password: ''
});
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Record<keyof FormData, boolean>>({
email: false,
password: false
});
// Validation function
const validateField = (name: keyof FormData, value: string): string | undefined => {
switch (name) {
case 'email':
if (!value) return 'Email is required';
if (!/\S+@\S+\.\S+/.test(value)) return 'Invalid email format';
return undefined;
case 'password':
if (!value) return 'Password is required';
if (value.length < 8) return 'Password must be at least 8 characters';
return undefined;
default:
return undefined;
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Clear error when user starts typing
if (touched[name as keyof FormData]) {
const error = validateField(name as keyof FormData, value);
setErrors(prev => ({ ...prev, [name]: error }));
}
};
const handleBlur = (name: keyof FormData) => {
setTouched(prev => ({ ...prev, [name]: true }));
const error = validateField(name, formData[name]);
setErrors(prev => ({ ...prev, [name]: error }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validate all fields
const newErrors: FormErrors = {};
(Object.keys(formData) as Array<keyof FormData>).forEach(field => {
const error = validateField(field, formData[field]);
if (error) newErrors[field] = error;
});
setErrors(newErrors);
// Mark all as touched
setTouched({ email: true, password: true });
// If no errors, submit
if (Object.keys(newErrors).length === 0) {
console.log('Form is valid! Submitting:', formData);
}
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: '400px' }}>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email">Email:</label>
<input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
onBlur={() => handleBlur('email')}
style={{
width: '100%',
padding: '0.5rem',
borderColor: touched.email && errors.email ? 'red' : '#ddd'
}}
/>
{touched.email && errors.email && (
<p style={{ color: 'red', fontSize: '0.875rem', marginTop: '0.25rem' }}>
{errors.email}
</p>
)}
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="password">Password:</label>
<input
id="password"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
onBlur={() => handleBlur('password')}
style={{
width: '100%',
🎮 Interactive Demo: Validation States
See how validation states change as you type:
padding: '0.5rem',
borderColor: touched.password && errors.password ? 'red' : '#ddd'
}}
/>
{touched.password && errors.password && (
<p style={{ color: 'red', fontSize: '0.875rem', marginTop: '0.25rem' }}>
{errors.password}
</p>
)}
</div>
<button
type="submit"
style={{
width: '100%',
padding: '0.75rem',
background: '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px'
}}
>
Submit
</button>
</form>
);
};
💡 Validation Best Practices
- Touched state: Only show errors after user interacts with field
- Clear errors on change: Remove error as user fixes it
- Validate on blur: Check when field loses focus
- Final validation on submit: Catch anything missed
- Helpful messages: Tell user what's wrong and how to fix it
Common Validation Rules
Reusable Validators
// Create a validators object
const validators = {
required: (value: string) =>
value.trim() === '' ? 'This field is required' : undefined,
email: (value: string) =>
!/\S+@\S+\.\S+/.test(value) ? 'Invalid email address' : undefined,
minLength: (min: number) => (value: string) =>
value.length < min ? `Must be at least ${min} characters` : undefined,
maxLength: (max: number) => (value: string) =>
value.length > max ? `Must be no more than ${max} characters` : undefined,
pattern: (regex: RegExp, message: string) => (value: string) =>
!regex.test(value) ? message : undefined,
match: (otherValue: string, label: string) => (value: string) =>
value !== otherValue ? `Must match ${label}` : undefined,
number: (value: string) =>
isNaN(Number(value)) ? 'Must be a number' : undefined,
url: (value: string) =>
!/^https?:\/\/.+/.test(value) ? 'Must be a valid URL' : undefined,
phone: (value: string) =>
!/^\+?[\d\s\-()]+$/.test(value) ? 'Invalid phone number' : undefined
};
// Usage:
const validateEmail = (value: string) => {
return validators.required(value) || validators.email(value);
};
const validatePassword = (value: string) => {
return validators.required(value) || validators.minLength(8)(value);
};
Composing Validators
Chain Multiple Rules
// Compose validators for complex rules
const composeValidators = (...validators: Array<(value: string) => string | undefined>) => {
return (value: string): string | undefined => {
for (const validator of validators) {
const error = validator(value);
if (error) return error;
}
return undefined;
};
};
// Usage - check multiple rules
const validateUsername = composeValidators(
validators.required,
validators.minLength(3),
validators.maxLength(20),
validators.pattern(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores allowed')
);
const usernameError = validateUsername('ab'); // "Must be at least 3 characters"
🚀 Form Submission
Let's handle form submission properly, including loading states, success messages, and error handling.
Basic Form Submission
Preventing Default and Handling Submit
const BasicSubmit: React.FC = () => {
const [formData, setFormData] = useState({ email: '', password: '' });
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
// CRITICAL: Prevent page reload!
e.preventDefault();
console.log('Submitting:', formData);
// Send to API, etc.
};
return (
<form onSubmit={handleSubmit}>
{/* inputs */}
<button type="submit">Submit</button>
</form>
);
};
⚠️ Always call e.preventDefault() to stop the default form submission!
Submit with Loading State
Async Submission with Feedback
interface FormData {
email: string;
password: string;
}
const AsyncForm: React.FC = () => {
const [formData, setFormData] = useState<FormData>({ email: '', password: '' });
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Reset states
setError(null);
setSuccess(false);
setIsLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 2000));
// In real app: await api.login(formData);
setSuccess(true);
console.log('Login successful!');
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong');
} finally {
setIsLoading(false);
}
};
// Don't show form after success
if (success) {
return (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<h2 style={{ color: '#4CAF50' }}>✅ Success!</h2>
<p>You have been logged in.</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} style={{ maxWidth: '400px' }}>
{error && (
<div style={{
padding: '1rem',
background: '#ffebee',
border: '1px solid #f44336',
borderRadius: '4px',
marginBottom: '1rem',
color: '#c62828'
}}>
❌ {error}
</div>
)}
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="email">Email:</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
disabled={isLoading}
required
style={{ width: '100%', padding: '0.5rem' }}
/>
</div>
<div style={{ marginBottom: '1rem' }}>
<label htmlFor="password">Password:</label>
<input
id="password"
type="password"
value={formData.password}
🎨 Form Submission Flow
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
disabled={isLoading}
required
style={{ width: '100%', padding: '0.5rem' }}
/>
</div>
<button
type="submit"
disabled={isLoading}
style={{
width: '100%',
padding: '0.75rem',
background: isLoading ? '#ccc' : '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isLoading ? 'not-allowed' : 'pointer'
}}
>
{isLoading ? 'Logging in...' : 'Login'}
</button>
</form>
);
};
Form Reset After Submission
Clear Form on Success
const ResetForm: React.FC = () => {
const initialState = { name: '', email: '', message: '' };
const [formData, setFormData] = useState(initialState);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Submit form...
await submitToAPI(formData);
// Reset form to initial state
setFormData(initialState);
// Or use form ref:
// e.currentTarget.reset();
};
return <form onSubmit={handleSubmit}>{/* ... */}</form>;
};
✅ Submission Checklist
- ✅ Call
e.preventDefault() - ✅ Show loading state during async operations
- ✅ Disable form while submitting
- ✅ Display error messages if submission fails
- ✅ Show success message or redirect on success
- ✅ Reset form if appropriate
- ✅ Handle network errors gracefully
🔒 Type-Safe Forms
TypeScript makes forms safer! Let's use types effectively to catch bugs before they happen.
Strongly Typed Form Data
Define Clear Interfaces
// Define your form shape
interface UserProfileForm {
firstName: string;
lastName: string;
age: number;
email: string;
bio: string;
terms: boolean;
}
// TypeScript ensures you handle all fields!
const ProfileForm: React.FC = () => {
const [formData, setFormData] = useState<UserProfileForm>({
firstName: '',
lastName: '',
age: 0,
email: '',
bio: '',
terms: false
});
// Type-safe change handler
const handleChange = (
field: keyof UserProfileForm,
value: string | number | boolean
) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
return <form>{/* ... */}</form>;
};
Type-Safe Event Handlers
Proper Event Types
// Different event types for different elements
const TypeSafeHandlers: React.FC = () => {
// For input elements
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value);
};
// For textarea elements
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
console.log(e.target.value);
};
// For select elements
const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
console.log(e.target.value);
};
// For form submission
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
// For button clicks
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
console.log('Button clicked');
};
return <form>{/* ... */}</form>;
};
Generic Form Component
Reusable Type-Safe Form
// Generic type-safe form hook
function useForm<T extends Record<string, any>>(initialValues: T) {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const handleChange = (field: keyof T, value: T[keyof T]) => {
setValues(prev => ({ ...prev, [field]: value }));
// Clear error for this field
setErrors(prev => ({ ...prev, [field]: undefined }));
};
const reset = () => {
setValues(initialValues);
setErrors({});
};
return {
values,
errors,
setErrors,
handleChange,
reset
};
}
// Usage - fully type-safe!
interface LoginForm {
email: string;
password: string;
}
const Login: React.FC = () => {
const { values, handleChange, reset } = useForm<LoginForm>({
email: '',
password: ''
});
// TypeScript knows values.email and values.password exist!
return (
<form>
<input
value={values.email}
onChange={(e) => handleChange('email', e.target.value)}
/>
<input
value={values.password}
onChange={(e) => handleChange('password', e.target.value)}
/>
</form>
);
};
💡 Type Safety Benefits
- Autocomplete: IDE suggests valid field names
- Catch typos: Misspelled field names cause errors
- Refactor safely: Rename fields with confidence
- Documentation: Interface shows form structure
- Fewer bugs: Type errors caught at compile time
🏋️ Hands-on Practice
Time to build some real forms! These exercises will solidify your understanding.
🏋️ Exercise 1: Survey Form
Goal: Build a survey form with various input types.
Requirements:
- Text input for name
- Email input with validation
- Radio buttons for satisfaction (1-5)
- Checkboxes for multiple interests
- Textarea for comments
- Submit button that logs all data
💡 Hint
Use a single state object and separate handlers for text inputs vs checkboxes.
✅ Solution
Try it yourself first! The patterns are all in the lesson above.
🏋️ Exercise 2: Login Form with Validation
Goal: Create a login form with complete validation.
Requirements:
- Email and password fields
- Email format validation
- Password minimum length (8 characters)
- Show errors only after field is touched
- Disable submit button if form invalid
- Loading state during submission
- Success/error messages
💡 Hint
Track three states: formData, errors, and touched.
🏋️ Exercise 3: Multi-Step Registration
Goal: Build a multi-step form with navigation.
Requirements:
- Step 1: Personal info (name, email)
- Step 2: Account (username, password)
- Step 3: Preferences (newsletter, notifications)
- Next/Previous buttons
- Progress indicator
- Validate current step before allowing next
- Review all data on final step
💡 Hint
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState({ /* all fields */ });
const nextStep = () => {
if (validateCurrentStep()) {
setCurrentStep(prev => prev + 1);
}
};
🚀 Advanced Patterns
Ready for pro-level form techniques? Let's explore advanced patterns.
Dynamic Form Fields
Add/Remove Fields Dynamically
interface PhoneNumber {
id: string;
number: string;
}
const DynamicFields: React.FC = () => {
const [phones, setPhones] = useState<PhoneNumber[]>([
{ id: '1', number: '' }
]);
const addPhone = () => {
setPhones([...phones, { id: Date.now().toString(), number: '' }]);
};
const removePhone = (id: string) => {
setPhones(phones.filter(phone => phone.id !== id));
};
const updatePhone = (id: string, number: string) => {
setPhones(phones.map(phone =>
phone.id === id ? { ...phone, number } : phone
));
};
return (
<div>
{phones.map((phone, index) => (
<div key={phone.id} style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.5rem' }}>
<input
type="tel"
value={phone.number}
onChange={(e) => updatePhone(phone.id, e.target.value)}
placeholder={`Phone ${index + 1}`}
/>
{phones.length > 1 && (
<button type="button" onClick={() => removePhone(phone.id)}>
Remove
</button>
)}
</div>
))}
<button type="button" onClick={addPhone}>
+ Add Phone
</button>
</div>
);
};
Conditional Fields
Show/Hide Based on Values
const ConditionalForm: React.FC = () => {
const [accountType, setAccountType] = useState<'personal' | 'business'>('personal');
const [formData, setFormData] = useState({
name: '',
email: '',
companyName: '', // Only for business
taxId: '' // Only for business
});
return (
<form>
<div>
<label>Account Type:</label>
<select
value={accountType}
onChange={(e) => setAccountType(e.target.value as any)}
>
<option value="personal">Personal</option>
<option value="business">Business</option>
</select>
</div>
{/* Always shown */}
<input
placeholder="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
{/* Conditional fields */}
{accountType === 'business' && (
<>
<input
placeholder="Company Name"
value={formData.companyName}
onChange={(e) => setFormData({ ...formData, companyName: e.target.value })}
/>
<input
placeholder="Tax ID"
value={formData.taxId}
onChange={(e) => setFormData({ ...formData, taxId: e.target.value })}
/>
</>
)}
</form>
);
};
Debounced Validation
Validate After User Stops Typing
const DebouncedValidation: React.FC = () => {
const [username, setUsername] = useState('');
const [isChecking, setIsChecking] = useState(false);
const [isAvailable, setIsAvailable] = useState<boolean | null>(null);
// Debounce function
useEffect(() => {
if (!username) {
setIsAvailable(null);
return;
}
setIsChecking(true);
const timer = setTimeout(async () => {
// Check if username is available
const available = await checkUsernameAvailability(username);
setIsAvailable(available);
setIsChecking(false);
}, 500); // Wait 500ms after user stops typing
return () => clearTimeout(timer);
}, [username]);
return (
<div>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Choose username"
/>
{isChecking && <span>Checking...</span>}
{!isChecking && isAvailable === true && (
<span style={{ color: 'green' }}>✓ Available</span>
)}
{!isChecking && isAvailable === false && (
<span style={{ color: 'red' }}>✗ Already taken</span>
)}
</div>
);
};
async function checkUsernameAvailability(username: string): Promise<boolean> {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
return username !== 'admin'; // Example: 'admin' is taken
}
✨ Best Practices
Follow these guidelines to build forms that users love and developers maintain easily.
✅ 1. Always Use Controlled Components
Keep React state as the single source of truth for form data.
// ✅ Good
const [email, setEmail] = useState('');
<input value={email} onChange={(e) => setEmail(e.target.value)} />
// ❌ Bad - uncontrolled
<input defaultValue="[email protected]" />
✅ 2. Provide Immediate Feedback
- Show validation errors as users type or on blur
- Display character counts for limited fields
- Show password strength indicators
- Indicate required fields clearly
✅ 3. Use Proper Input Types
// ✅ Use semantic types
<input type="email" /> // Mobile keyboards show @
<input type="tel" /> // Numeric keyboard on mobile
<input type="number" /> // Number input
<input type="date" /> // Date picker
<input type="url" /> // URL validation
✅ 4. Accessibility Matters
- Always use
<label>withhtmlFor - Add
aria-labelwhere labels aren't visible - Use
aria-describedbyfor error messages - Ensure keyboard navigation works
- Test with screen readers
✅ 5. Handle Loading States
const [isLoading, setIsLoading] = useState(false);
<button type="submit" disabled={isLoading}>
{isLoading ? 'Submitting...' : 'Submit'}
</button>
// Disable all inputs during submission
<input disabled={isLoading} />
✅ 6. Don't Trust Client-Side Validation
Always validate on the server too! Client-side validation is for UX, not security.
Forms Checklist
| Category | Checklist Item |
|---|---|
| Functionality | ✅ All inputs controlled by state ✅ Form prevents default submission ✅ Validation works correctly ✅ Loading states handled |
| User Experience | ✅ Clear error messages ✅ Errors shown at right time ✅ Success feedback provided ✅ Submit button disabled when invalid |
| Accessibility | ✅ Labels on all inputs ✅ Keyboard navigation works ✅ Focus management correct ✅ ARIA attributes where needed |
| Type Safety | ✅ Form data interface defined ✅ Event handlers properly typed ✅ No any types used |
📚 Summary
Congratulations! You've mastered forms in React - one of the most important skills for building real applications.
What You Learned
🎛️ Controlled Components
- Difference between controlled and uncontrolled components
- Why React prefers controlled components
- How data flows in controlled inputs
📝 Building Forms
- Creating basic controlled inputs
- Handling multiple inputs efficiently
- Working with different input types
- Managing form state with single object
✅ Validation
- When to validate (onChange, onBlur, onSubmit)
- Tracking touched fields
- Displaying validation errors
- Creating reusable validators
🚀 Submission
- Preventing default form behavior
- Handling async submissions
- Managing loading states
- Showing success/error feedback
🔒 Type Safety
- Typing form data with interfaces
- Proper event handler types
- Creating type-safe form utilities
🎯 Key Takeaways
- Control is power - Controlled components give you full control over form data
- One state object - Use a single object for related form fields
- Validate smartly - Validate at the right time for best UX
- Type everything - TypeScript makes forms safer and easier to maintain
- Feedback matters - Always show users what's happening
- Accessibility first - Labels, ARIA, and keyboard support are essential
🚀 Next Steps
Now that you've mastered forms, you're ready for more advanced topics:
📖 Coming Up Next
Lesson 3.4: Lists and Keys
- Rendering dynamic lists
- Understanding the key prop
- CRUD operations on lists
- List performance optimization
💪 Practice Projects
Build these projects to master forms:
- Job Application Form - Multi-step with file upload
- Quiz Builder - Dynamic questions with add/remove
- Booking System - Date picker, time slots, payment info
- Profile Editor - Update user info with validation
- Survey Tool - All input types, skip logic, results summary
✨ Pro Tip
The patterns you learned here are the foundation, but real-world apps often use form libraries like React Hook Form or Formik. These libraries handle the plumbing we built manually, but understanding the fundamentals you learned here is crucial for using them effectively!
📚 Additional Resources
- React Docs: Input Components
- React Docs: Form Components
- React Hook Form - Popular form library
- W3C: Accessible Forms
🎉 Congratulations!
You've completed Lesson 3.3 and mastered forms in React! You can now build complex, validated, accessible forms that provide great user experiences. This is a critical skill for any React developer. Well done! 🚀