React.js Fundamentals

Forms & Controlled Components

18 min Lesson 10 of 40

Understanding Forms in React

Forms are essential for user interaction in web applications. In React, form elements work differently from other DOM elements because they naturally maintain some internal state. React provides two approaches to handling forms: controlled and uncontrolled components.

Core Concept: A controlled component is a form element whose value is controlled by React state. React becomes the "single source of truth" for the form data, making it predictable and easy to manage.

Controlled vs Uncontrolled Components

Understanding the difference between controlled and uncontrolled components is crucial:

// UNCONTROLLED: DOM manages the value function UncontrolledForm() { const inputRef = useRef(null); const handleSubmit = (e) => { e.preventDefault(); // Access value from DOM directly console.log(inputRef.current.value); }; return ( <form onSubmit={handleSubmit}> <input type="text" ref={inputRef} /> <button type="submit">Submit</button> </form> ); } // CONTROLLED: React state manages the value function ControlledForm() { const [value, setValue] = useState(''); const handleSubmit = (e) => { e.preventDefault(); // Value is always in sync with state console.log(value); }; return ( <form onSubmit={handleSubmit}> <input type="text" value={value} onChange={(e) => setValue(e.target.value)} /> <button type="submit">Submit</button> </form> ); }
Best Practice: Use controlled components in most cases. They give you full control over form data, enable validation, conditional rendering, and make debugging easier. Use uncontrolled components only when integrating with non-React code or when performance is critical.

Text Input - Controlled Component

The most common form element is the text input. Here's how to make it controlled:

import { useState } from 'react'; function NameForm() { const [name, setName] = useState(''); const handleSubmit = (event) => { event.preventDefault(); alert(`Submitted name: ${name}`); }; const handleChange = (event) => { // Update state on every keystroke setName(event.target.value); }; return ( <form onSubmit={handleSubmit}> <label> Name: <input type="text" value={name} onChange={handleChange} placeholder="Enter your name" /> </label> <p>Current value: {name}</p> <button type="submit">Submit</button> </form> ); }

Textarea Element

In React, a <textarea> uses a value attribute instead of children, making it consistent with other input elements:

function EssayForm() { const [essay, setEssay] = useState(''); const handleSubmit = (event) => { event.preventDefault(); alert(`Essay submitted with ${essay.length} characters`); }; return ( <form onSubmit={handleSubmit}> <label> Essay: <textarea value={essay} onChange={(e) => setEssay(e.target.value)} placeholder="Write your essay here..." rows={5} /> </label> <p>Character count: {essay.length}</p> <button type="submit">Submit</button> </form> ); }

Select Element (Dropdown)

The <select> element also uses a value attribute to control which option is selected:

function FlavorForm() { const [flavor, setFlavor] = useState('coconut'); const handleSubmit = (event) => { event.preventDefault(); alert(`Your favorite flavor is: ${flavor}`); }; return ( <form onSubmit={handleSubmit}> <label> Pick your favorite flavor: <select value={flavor} onChange={(e) => setFlavor(e.target.value)}> <option value="grapefruit">Grapefruit</option> <option value="lime">Lime</option> <option value="coconut">Coconut</option> <option value="mango">Mango</option> </select> </label> <button type="submit">Submit</button> </form> ); }

Checkbox and Radio Inputs

Checkboxes and radio buttons use the checked attribute instead of value:

function PreferencesForm() { const [preferences, setPreferences] = useState({ newsletter: false, notifications: true, theme: 'light' }); const handleCheckboxChange = (event) => { const { name, checked } = event.target; setPreferences(prev => ({ ...prev, [name]: checked })); }; const handleRadioChange = (event) => { setPreferences(prev => ({ ...prev, theme: event.target.value })); }; return ( <form> {/* Checkboxes */} <label> <input type="checkbox" name="newsletter" checked={preferences.newsletter} onChange={handleCheckboxChange} /> Subscribe to newsletter </label> <br /> <label> <input type="checkbox" name="notifications" checked={preferences.notifications} onChange={handleCheckboxChange} /> Enable notifications </label> <br /> {/* Radio buttons */} <p>Theme:</p> <label> <input type="radio" value="light" checked={preferences.theme === 'light'} onChange={handleRadioChange} /> Light </label> <label> <input type="radio" value="dark" checked={preferences.theme === 'dark'} onChange={handleRadioChange} /> Dark </label> <p>Current preferences: {JSON.stringify(preferences, null, 2)}</p> </form> ); }
Common Mistake: Forgetting to bind value to state makes the input uncontrolled. Always use value={state} or checked={state} along with onChange handler to keep the input controlled.

Handling Multiple Inputs

When dealing with multiple inputs, you can use a single handler function with computed property names:

function RegistrationForm() { const [formData, setFormData] = useState({ username: '', email: '', password: '', age: '', country: 'usa' }); // Single handler for all inputs const handleChange = (event) => { const { name, value, type, checked } = event.target; setFormData(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value })); }; const handleSubmit = (event) => { event.preventDefault(); console.log('Form submitted:', formData); }; return ( <form onSubmit={handleSubmit}> <input type="text" name="username" value={formData.username} onChange={handleChange} placeholder="Username" /> <br /> <input type="email" name="email" value={formData.email} onChange={handleChange} placeholder="Email" /> <br /> <input type="password" name="password" value={formData.password} onChange={handleChange} placeholder="Password" /> <br /> <input type="number" name="age" value={formData.age} onChange={handleChange} placeholder="Age" /> <br /> <select name="country" value={formData.country} onChange={handleChange}> <option value="usa">USA</option> <option value="uk">UK</option> <option value="canada">Canada</option> </select> <br /> <button type="submit">Register</button> </form> ); }

Form Validation

Controlled components make validation straightforward. You can validate on every change or on submit:

function LoginForm() { const [formData, setFormData] = useState({ email: '', password: '' }); const [errors, setErrors] = useState({}); const validateField = (name, value) => { switch(name) { case 'email': if (!value) return 'Email is required'; if (!/\S+@\S+\.\S+/.test(value)) return 'Email is invalid'; return ''; case 'password': if (!value) return 'Password is required'; if (value.length < 6) return 'Password must be at least 6 characters'; return ''; default: return ''; } }; const handleChange = (event) => { const { name, value } = event.target; // Update form data setFormData(prev => ({ ...prev, [name]: value })); // Validate on change const error = validateField(name, value); setErrors(prev => ({ ...prev, [name]: error })); }; const handleSubmit = (event) => { event.preventDefault(); // Validate all fields const newErrors = { email: validateField('email', formData.email), password: validateField('password', formData.password) }; setErrors(newErrors); // Check if there are any errors const hasErrors = Object.values(newErrors).some(error => error); if (!hasErrors) { console.log('Form is valid, submitting:', formData); // Submit form } }; return ( <form onSubmit={handleSubmit}> <div> <input type="email" name="email" value={formData.email} onChange={handleChange} placeholder="Email" /> {errors.email && ( <p style={{ color: 'red' }}>{errors.email}</p> )} </div> <div> <input type="password" name="password" value={formData.password} onChange={handleChange} placeholder="Password" /> {errors.password && ( <p style={{ color: 'red' }}>{errors.password}</p> )} </div> <button type="submit">Login</button> <form> ); }
Validation Strategy: For better UX, validate on blur (when user leaves the field) rather than on every keystroke. Validate all fields on submit to catch any missed errors.

File Input

File inputs are always uncontrolled in React because their value can only be set by the user, not programmatically:

function FileUploadForm() { const [selectedFile, setSelectedFile] = useState(null); const fileInputRef = useRef(null); const handleFileChange = (event) => { const file = event.target.files[0]; if (file) { setSelectedFile({ name: file.name, size: file.size, type: file.type }); } }; const handleSubmit = (event) => { event.preventDefault(); if (selectedFile) { console.log('Uploading file:', selectedFile); // Perform file upload } }; const handleClear = () => { setSelectedFile(null); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; return ( <form onSubmit={handleSubmit}> <input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*,.pdf" /> {selectedFile && ( <div> <p>Selected: {selectedFile.name}</p> <p>Size: {(selectedFile.size / 1024).toFixed(2)} KB</p> <p>Type: {selectedFile.type}</p> <button type="button" onClick={handleClear}>Clear</button> </div> )} <button type="submit" disabled={!selectedFile}> Upload </button> </form> ); }

Complex Form Example

Here's a comprehensive example combining multiple form elements with validation:

function CompleteRegistrationForm() { const [formData, setFormData] = useState({ firstName: '', lastName: '', email: '', password: '', confirmPassword: '', age: '', gender: '', country: 'usa', bio: '', agreeToTerms: false }); const [errors, setErrors] = useState({}); const [touched, setTouched] = useState({}); const handleChange = (e) => { const { name, value, type, checked } = e.target; setFormData(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value })); }; const handleBlur = (e) => { const { name } = e.target; setTouched(prev => ({ ...prev, [name]: true })); validateField(name, formData[name]); }; const validateField = (name, value) => { let error = ''; switch(name) { case 'firstName': case 'lastName': if (!value.trim()) error = `${name} is required`; break; case 'email': if (!value) error = 'Email is required'; else if (!/\S+@\S+\.\S+/.test(value)) error = 'Invalid email'; break; case 'password': if (!value) error = 'Password is required'; else if (value.length < 8) error = 'Min 8 characters'; break; case 'confirmPassword': if (value !== formData.password) error = 'Passwords must match'; break; case 'age': if (value && (value < 18 || value > 100)) error = 'Age must be 18-100'; break; } setErrors(prev => ({ ...prev, [name]: error })); return error; }; const handleSubmit = (e) => { e.preventDefault(); // Validate all fields const allErrors = {}; Object.keys(formData).forEach(key => { const error = validateField(key, formData[key]); if (error) allErrors[key] = error; }); if (!formData.agreeToTerms) { allErrors.agreeToTerms = 'You must agree to terms'; } setErrors(allErrors); if (Object.keys(allErrors).length === 0) { console.log('Form submitted successfully:', formData); // Submit to server } }; return ( <form onSubmit={handleSubmit}> {/* First Name */} <input name="firstName" value={formData.firstName} onChange={handleChange} onBlur={handleBlur} placeholder="First Name" /> {touched.firstName && errors.firstName && ( <span className="error">{errors.firstName}</span> )} {/* Add other fields similarly... */} {/* Terms checkbox */} <label> <input type="checkbox" name="agreeToTerms" checked={formData.agreeToTerms} onChange={handleChange} /> I agree to terms and conditions </label> {errors.agreeToTerms && ( <span className="error">{errors.agreeToTerms}</span> )} <button type="submit">Register</button> </form> ); }
Form Libraries: For complex forms with advanced validation, consider using form libraries like Formik, React Hook Form, or React Final Form. They provide built-in validation, error handling, and reduce boilerplate code.

Exercise 1: Contact Form with Validation

Create a contact form with the following requirements:

  • Fields: Name (required), Email (required, valid format), Phone (optional, 10 digits), Message (required, min 10 chars)
  • Show error messages only after user leaves the field (onBlur)
  • Disable submit button if form has errors
  • Show character count for message field
  • After successful submission, show success message and clear form
  • Add a reset button to clear all fields

Hint: Use touched state to track which fields the user has interacted with.

Exercise 2: Dynamic Survey Form

Build a survey form that changes based on user responses:

  • Start with: Name, Age, Employment status (dropdown: Employed/Student/Unemployed)
  • If "Employed": show Company name and Position fields
  • If "Student": show School name and Major fields
  • All users: Show checkbox list of interests (Sports, Music, Reading, Gaming, Travel)
  • Based on selected interests, show follow-up questions (e.g., if Music: favorite genre)
  • Calculate and display survey completion percentage
  • Summary page showing all answers before submit

Bonus: Add navigation between sections with validation on each step.

Exercise 3: E-commerce Checkout Form

Create a multi-step checkout form:

  • Step 1: Shipping info (Full name, Address, City, State, ZIP, Country)
  • Step 2: Payment method (radio: Credit card/PayPal/Bank transfer)
  • If Credit card: show card number, expiry, CVV fields
  • Step 3: Review order with all details and total price
  • Validate each step before allowing next step
  • Add "Back" and "Next" buttons
  • Save form data to localStorage to persist between refreshes
  • Add progress indicator showing current step

Hint: Use multiple state objects, one for each step, and a step counter.