Form Patterns and Best Practices
Now that you understand form basics and React 19's new features, let's explore advanced patterns for building robust, accessible forms.
Single Handler for Multiple Inputs
Use name attribute to handle all inputs with one function:
jsx1function RegistrationForm() {2 const [formData, setFormData] = useState({3 firstName: '',4 lastName: '',5 email: '',6 password: '',7 agreeToTerms: false8 });910 const handleChange = (e) => {11 const { name, value, type, checked } = e.target;12 setFormData(prev => ({13 ...prev,14 [name]: type === 'checkbox' ? checked : value15 }));16 };1718 return (19 <form>20 <input name="firstName" value={formData.firstName} onChange={handleChange} />21 <input name="lastName" value={formData.lastName} onChange={handleChange} />22 <input name="email" type="email" value={formData.email} onChange={handleChange} />23 <input name="password" type="password" value={formData.password} onChange={handleChange} />24 <input25 name="agreeToTerms"26 type="checkbox"27 checked={formData.agreeToTerms}28 onChange={handleChange}29 />30 </form>31 );32}
Uncontrolled Components with Refs
Sometimes you don't need state for every keystroke:
jsx1function QuickForm() {2 const inputRef = useRef(null);34 const handleSubmit = (e) => {5 e.preventDefault();6 console.log('Value:', inputRef.current.value);7 };89 return (10 <form onSubmit={handleSubmit}>11 <input ref={inputRef} defaultValue="Initial" />12 <button type="submit">Submit</button>13 </form>14 );15}
Use uncontrolled when:
- Value only needed on submit
- Integrating with non-React code
- File inputs (always uncontrolled)
File Input Handling
File inputs are always uncontrolled:
jsx1function FileUpload() {2 const [file, setFile] = useState(null);3 const [preview, setPreview] = useState(null);45 const handleChange = (e) => {6 const selectedFile = e.target.files[0];7 setFile(selectedFile);89 // Create preview for images10 if (selectedFile?.type.startsWith('image/')) {11 const reader = new FileReader();12 reader.onload = (e) => setPreview(e.target.result);13 reader.readAsDataURL(selectedFile);14 }15 };1617 return (18 <div>19 <input type="file" accept="image/*" onChange={handleChange} />20 {preview && <img src={preview} alt="Preview" style={{ maxWidth: 200 }} />}21 {file && <p>Selected: {file.name} ({file.size} bytes)</p>}22 </div>23 );24}
Validation Strategies
1. Validate on Submit
jsx1function FormWithSubmitValidation() {2 const [email, setEmail] = useState('');3 const [errors, setErrors] = useState({});45 const validate = () => {6 const newErrors = {};7 if (!email) newErrors.email = 'Email is required';8 else if (!/\S+@\S+\.\S+/.test(email)) newErrors.email = 'Invalid email';9 return newErrors;10 };1112 const handleSubmit = (e) => {13 e.preventDefault();14 const newErrors = validate();15 if (Object.keys(newErrors).length > 0) {16 setErrors(newErrors);17 return;18 }19 // Submit...20 };2122 return (23 <form onSubmit={handleSubmit}>24 <input value={email} onChange={e => setEmail(e.target.value)} />25 {errors.email && <span className="error">{errors.email}</span>}26 <button type="submit">Submit</button>27 </form>28 );29}
2. Validate on Blur (Field Exit)
jsx1function FormWithBlurValidation() {2 const [email, setEmail] = useState('');3 const [touched, setTouched] = useState(false);45 const error = touched && !email.includes('@') ? 'Invalid email' : null;67 return (8 <form>9 <input10 value={email}11 onChange={e => setEmail(e.target.value)}12 onBlur={() => setTouched(true)}13 />14 {error && <span className="error">{error}</span>}15 </form>16 );17}
3. Real-time Validation
jsx1function FormWithRealtimeValidation() {2 const [password, setPassword] = useState('');34 const checks = {5 length: password.length >= 8,6 uppercase: /[A-Z]/.test(password),7 lowercase: /[a-z]/.test(password),8 number: /\d/.test(password),9 };1011 const isValid = Object.values(checks).every(Boolean);1213 return (14 <div>15 <input16 type="password"17 value={password}18 onChange={e => setPassword(e.target.value)}19 />20 <ul className="password-checks">21 <li className={checks.length ? 'valid' : ''}>At least 8 characters</li>22 <li className={checks.uppercase ? 'valid' : ''}>One uppercase letter</li>23 <li className={checks.lowercase ? 'valid' : ''}>One lowercase letter</li>24 <li className={checks.number ? 'valid' : ''}>One number</li>25 </ul>26 <button disabled={!isValid}>Create Account</button>27 </div>28 );29}
Error Display Patterns
Field-Level Errors
jsx1function FormField({ label, name, error, ...props }) {2 const id = `field-${name}`;3 const errorId = `${id}-error`;45 return (6 <div className="form-field">7 <label htmlFor={id}>{label}</label>8 <input9 id={id}10 name={name}11 aria-invalid={!!error}12 aria-describedby={error ? errorId : undefined}13 {...props}14 />15 {error && (16 <span id={errorId} className="error" role="alert">17 {error}18 </span>19 )}20 </div>21 );22}
Form-Level Error Summary
jsx1function FormWithSummary({ errors }) {2 const hasErrors = Object.keys(errors).length > 0;34 return (5 <>6 {hasErrors && (7 <div className="error-summary" role="alert">8 <h3>Please fix the following errors:</h3>9 <ul>10 {Object.entries(errors).map(([field, message]) => (11 <li key={field}>12 <a href={`#field-${field}`}>{message}</a>13 </li>14 ))}15 </ul>16 </div>17 )}18 {/* Form fields... */}19 </>20 );21}
Accessible Forms
Build forms that work for everyone:
jsx1function AccessibleForm() {2 return (3 <form aria-label="Contact form">4 {/* Labels with htmlFor */}5 <div>6 <label htmlFor="name">Name (required)</label>7 <input8 id="name"9 name="name"10 required11 aria-required="true"12 />13 </div>1415 {/* Field with description */}16 <div>17 <label htmlFor="email">Email</label>18 <input19 id="email"20 name="email"21 type="email"22 aria-describedby="email-hint"23 />24 <small id="email-hint">We'll never share your email</small>25 </div>2627 {/* Radio group with fieldset */}28 <fieldset>29 <legend>Contact preference</legend>30 <label>31 <input type="radio" name="contact" value="email" />32 Email33 </label>34 <label>35 <input type="radio" name="contact" value="phone" />36 Phone37 </label>38 </fieldset>3940 {/* Error announcement */}41 <div role="alert" aria-live="polite">42 {/* Errors will be announced by screen readers */}43 </div>4445 <button type="submit">Submit</button>46 </form>47 );48}
Key accessibility practices:
- Always use
<label>withhtmlFor - Use
aria-invalidfor error states - Use
aria-describedbyfor hints and errors - Use
fieldset/legendfor radio groups - Use
role="alert"for error messages
Form State Management Patterns
useReducer for Complex Forms
jsx1const initialState = {2 values: { email: '', password: '' },3 errors: {},4 touched: {},5 isSubmitting: false6};78function formReducer(state, action) {9 switch (action.type) {10 case 'SET_FIELD':11 return {12 ...state,13 values: { ...state.values, [action.field]: action.value }14 };15 case 'SET_ERROR':16 return {17 ...state,18 errors: { ...state.errors, [action.field]: action.error }19 };20 case 'TOUCH_FIELD':21 return {22 ...state,23 touched: { ...state.touched, [action.field]: true }24 };25 case 'SUBMIT_START':26 return { ...state, isSubmitting: true };27 case 'SUBMIT_END':28 return { ...state, isSubmitting: false };29 case 'RESET':30 return initialState;31 default:32 return state;33 }34}3536function ComplexForm() {37 const [state, dispatch] = useReducer(formReducer, initialState);3839 const handleChange = (e) => {40 dispatch({41 type: 'SET_FIELD',42 field: e.target.name,43 value: e.target.value44 });45 };4647 // ... rest of form48}
Custom useForm Hook
jsx1function useForm(initialValues, validate) {2 const [values, setValues] = useState(initialValues);3 const [errors, setErrors] = useState({});4 const [touched, setTouched] = useState({});56 const handleChange = (e) => {7 const { name, value, type, checked } = e.target;8 setValues(prev => ({9 ...prev,10 [name]: type === 'checkbox' ? checked : value11 }));12 };1314 const handleBlur = (e) => {15 setTouched(prev => ({ ...prev, [e.target.name]: true }));16 if (validate) {17 setErrors(validate(values));18 }19 };2021 const handleSubmit = (onSubmit) => (e) => {22 e.preventDefault();23 const validationErrors = validate ? validate(values) : {};24 setErrors(validationErrors);25 setTouched(Object.keys(values).reduce((acc, key) => ({ ...acc, [key]: true }), {}));2627 if (Object.keys(validationErrors).length === 0) {28 onSubmit(values);29 }30 };3132 const reset = () => {33 setValues(initialValues);34 setErrors({});35 setTouched({});36 };3738 return {39 values,40 errors,41 touched,42 handleChange,43 handleBlur,44 handleSubmit,45 reset46 };47}
Third-Party Form Libraries
For complex forms, consider:
React Hook Form - Performant, minimal re-renders:
jsx1import { useForm } from 'react-hook-form';23function Form() {4 const { register, handleSubmit, errors } = useForm();56 return (7 <form onSubmit={handleSubmit(data => console.log(data))}>8 <input {...register('email', { required: true })} />9 {errors.email && <span>Email required</span>}10 </form>11 );12}
Formik - Full-featured form management:
jsx1import { Formik, Form, Field, ErrorMessage } from 'formik';23function MyForm() {4 return (5 <Formik6 initialValues={{ email: '' }}7 validate={values => {8 const errors = {};9 if (!values.email) errors.email = 'Required';10 return errors;11 }}12 onSubmit={values => console.log(values)}13 >14 <Form>15 <Field name="email" type="email" />16 <ErrorMessage name="email" />17 <button type="submit">Submit</button>18 </Form>19 </Formik>20 );21}
Summary
Form best practices:
- Use controlled inputs for most cases
- Single handler with
nameattribute - Validate appropriately - submit, blur, or real-time
- Display errors clearly at field and form level
- Build accessible forms with proper ARIA
- Consider React 19 actions for server integration
- Use libraries for complex forms
Next, let's build a multi-step registration form that applies all these patterns!