Skip to main content

๐Ÿ“‹ 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 lives in React state. React is the "single source of truth" โ€” the input can only ever display what state says it should.

Uncontrolled Component: A form element that keeps its own value inside the DOM. The browser is the source of truth, and you reach in to read the value when you need it.

Analogy: a controlled input is a thermostat you set to a target temperature (state) and the room follows it. An uncontrolled input is a window you crank open and only check later to see how far it went.

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

๐ŸŽจ Visual Comparison

Controlled versus uncontrolled components Side-by-side diagram: a controlled input reads its value from React state and updates it on change, while an uncontrolled input stores its value in the DOM and is read via a ref. Controlled vs Uncontrolled Components โœ… Controlled Component React State useState("value") value={state} value prop onChange Benefits: โœ“ Always know current value โœ“ Easy real-time validation โœ“ Can transform input โœ“ Predictable state โœ“ React is source of truth VS โš ๏ธ Uncontrolled Component DOM stores value defaultValue ref={inputRef} query DOM Limitations: โœ— Must query DOM for value โœ— Hard to validate on change โœ— Can't easily transform โœ— Less predictable โœ— DOM is source of truth
Figure 1: In a controlled component, state flows down into the input as value and every keystroke flows back up through onChange. Uncontrolled inputs skip that loop and keep their value in the DOM.

โœ… 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<HTMLFormElement>) => {
        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<HTMLFormElement>) => {
        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

Every controlled input follows the same tiny loop, no matter how fancy the form gets: state renders the value, the user types, onChange writes back to state, and the re-render shows the new value. Once that loop clicks into place in your head, forms stop feeling mysterious. Let's build it up from the simplest possible input.

flowchart LR S["useState value"] -->|"value={name}"| I["input on screen"] I -->|"user types"| E["onChange event"] E -->|"setName(e.target.value)"| S S -.->|"re-render"| I

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 state
  • onChange - Updates state when user types
  • e.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 htmlFor on labels (connects to input id)
  • Set appropriate type attributes (email, password, tel, etc.)
  • Add placeholder text to guide users
  • Use autoComplete to 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;
        setFormData(prev => ({
            ...prev,
            [name]: value
        }));
    };
    
    const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
        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:

  1. Single state object with interface
  2. One generic handleChange function
  3. Use name attribute to identify inputs
  4. Use computed property names [name]

๐ŸŽฎ Does It Type-Check? Single-State-Object Edition

Given the typed handler handleChange(e: React.ChangeEvent<HTMLInputElement>) and a ContactFormData interface, click each line to see whether TypeScript accepts it:

interface ContactFormData { name: string; email: string; subject: string; message: string }

๐Ÿ‘† Click an example to see what TypeScript thinks.

๐ŸŽจ 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<HTMLFormElement>) => {
        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>
        </form>
    );
};

๐ŸŽจ Input Types Visual Reference

React Input Types: Value vs Checked value={state} + onChange ๐Ÿ“ text/email/password value={text} ๐Ÿ”ข number value={num} ๐Ÿ“„ textarea value={text} ๐Ÿ“‹ select value={option} e.target. value // Returns the string value checked={state} + onChange โ˜‘๏ธ checkbox checked={bool} ๐Ÿ”˜ radio checked={===} โš ๏ธ Key Difference: โ€ข Use checked, NOT value โ€ข Get boolean from e.target.checked e.target. checked // Returns true or false
Figure: Text-style inputs (text, email, password, number, textarea, select) are driven by value and read from e.target.value. Checkbox and radio are driven by checked and read from e.target.checked.

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<HTMLFormElement>) => {
        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%',
                        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

๐ŸŽฎ Trace the Validation State

The golden rule of friendly validation: an error can exist from the very first render (an empty required field is already invalid), but you only show it once the field is touched. Click each state below to see what the user would experience:

const showError = touched[field] && errors[field] !== undefined;

๐Ÿ‘† Click a state to see whether an error is displayed.

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<HTMLFormElement>) => {
        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}
                    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 Submission Flow

Form Submission: Complete Flow 1. User Clicks Submit Button ๐Ÿ–ฑ๏ธ Click event 2. preventDefault e.preventDefault() โš ๏ธ CRITICAL! 3. Validate Check all fields โœ“ Pass / โœ— Fail 4. Set Loading setLoading(true) โณ Disable form 5. API Call await api.submit() ๐ŸŒ Send data Success 6a. Success Show message โœ… Reset/Redirect Error 6b. Error setError(msg) โŒ Show error Finally setLoading(false) ๐Ÿ”„ Re-enable form ๐Ÿ’ก Without e.preventDefault(): โ€ข Page reloads (bad!) โ€ข Form data is lost
Figure: The submit lifecycle. Call e.preventDefault() first, validate, flip on a loading flag, await the request, then branch to a success or error state and always clear loading in a finally block.

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<HTMLFormElement>) => {
        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

๐Ÿ‘€ The Modern Direction: React 19 Form Actions

Everything above โ€” the loading flag, the error state, the try/catch/finally โ€” is plumbing you write by hand. React 19 (stable since December 2024) can manage a lot of it for you through form Actions and the useActionState hook. You pass an async function straight to a form's action prop, and React tracks pending state and the returned result automatically.

Same login form, React 19 style

import { useActionState } from 'react';

interface LoginState {
    error: string | null;
}

async function loginAction(
    _prev: LoginState,
    formData: FormData
): Promise<LoginState> {
    const email = formData.get('email') as string;
    const password = formData.get('password') as string;
    try {
        await api.login({ email, password });
        return { error: null };
    } catch (err) {
        return { error: err instanceof Error ? err.message : 'Login failed' };
    }
}

const LoginForm: React.FC = () => {
    // `isPending` is managed by React โ€” no manual useState(false)!
    const [state, submitAction, isPending] = useActionState(loginAction, { error: null });

    return (
        <form action={submitAction}>
            <input name="email" type="email" required />
            <input name="password" type="password" required />
            {state.error && <p role="alert">{state.error}</p>}
            <button type="submit" disabled={isPending}>
                {isPending ? 'Logging inโ€ฆ' : 'Login'}
            </button>
        </form>
    );
};

๐Ÿ’ก Which should you reach for?

Controlled inputs (the pattern that fills most of this lesson) are still the right tool when you need the live value on every keystroke โ€” real-time validation, formatting, dependent fields, character counters. Form Actions shine for the classic "fill it in, submit it once" flow, where you only care about the values at submit time and want React to own the pending/result bookkeeping. Learn controlled components first: Actions build directly on the same mental model, and the companion hooks (useFormStatus, useOptimistic) assume you already understand it.

๐Ÿ”’ 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 (React can't see edits after the first render)
<input defaultValue="initial text" />

โœ… 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> with htmlFor
  • Add aria-label where labels aren't visible
  • Use aria-describedby for 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!

๐ŸŽ‰ 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! ๐Ÿš€

โ† Previous Lesson 3.2: State Management Patterns โŒ‚ Home Next โ†’ Lesson 3.4: Lists and Keys