--- name: forms-validation description: MUI form patterns with validation and library integration triggers: - form - validation - TextField - FormControl - react-hook-form - formik allowed-tools: - Read - Glob - Grep - Write - Edit globs: - "*.tsx" - "*.jsx" --- # MUI Forms and Validation ## Controlled vs Uncontrolled Patterns ### Controlled (recommended for most cases) State lives in React. Every keystroke triggers a re-render; use for small-to-medium forms. ```tsx function ControlledForm() { const [name, setName] = React.useState(''); const [email, setEmail] = React.useState(''); const [error, setError] = React.useState(null); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!email.includes('@')) { setError('Enter a valid email address'); return; } // submit... }; return ( setName(e.target.value)} fullWidth margin="normal" /> { setEmail(e.target.value); setError(null); // clear error on change }} error={!!error} helperText={error} fullWidth margin="normal" /> ); } ``` ### Uncontrolled with refs Use for very large forms where performance matters, or when integrating with non-React code. ```tsx function UncontrolledForm() { const nameRef = React.useRef(null); const emailRef = React.useRef(null); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const data = { name: nameRef.current?.value, email: emailRef.current?.value, }; // submit data }; return ( ); } ``` --- ## TextField Error and Helper Text Patterns ```tsx // error flag turns label and border red; helperText shows message below 0 && password.length < 8} helperText={ password.length > 0 && password.length < 8 ? 'Password must be at least 8 characters' : 'Use a strong, unique password' } value={password} onChange={(e) => setPassword(e.target.value)} fullWidth /> // Character counter in helperText setBio(e.target.value)} inputProps={{ maxLength: 200 }} helperText={`${bio.length}/200`} FormHelperTextProps={{ sx: { textAlign: 'right' } }} fullWidth /> ``` --- ## FormControl / FormLabel / FormHelperText (Non-TextField) Use these primitives for custom inputs like checkbox groups or radio groups where `TextField` does not apply. ```tsx import FormControl from '@mui/material/FormControl'; import FormLabel from '@mui/material/FormLabel'; import FormGroup from '@mui/material/FormGroup'; import FormControlLabel from '@mui/material/FormControlLabel'; import FormHelperText from '@mui/material/FormHelperText'; import Checkbox from '@mui/material/Checkbox'; function NotificationPreferences() { const [prefs, setPrefs] = React.useState({ email: true, sms: false, push: true }); const [error, setError] = React.useState(false); const handleChange = (e: React.ChangeEvent) => { const updated = { ...prefs, [e.target.name]: e.target.checked }; setPrefs(updated); setError(!Object.values(updated).some(Boolean)); // at least one required }; return ( Notification channels } label="Email notifications" /> } label="SMS notifications" /> } label="Push notifications" /> {error && Select at least one notification channel.} ); } ``` --- ## React Hook Form + MUI React Hook Form is the recommended library for complex forms. Use the `Controller` component to integrate with MUI controlled inputs. Avoid spreading `register()` directly on MUI inputs — use `Controller` instead. ```tsx import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; const schema = z.object({ firstName: z.string().min(1, 'First name is required').max(50), email: z.string().email('Enter a valid email address'), role: z.enum(['admin', 'editor', 'viewer'], { required_error: 'Select a role' }), notifications: z.boolean(), tags: z.array(z.string()).min(1, 'Select at least one tag'), }); type FormValues = z.infer; function UserForm({ onSubmit }: { onSubmit: (data: FormValues) => void }) { const { control, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(schema), defaultValues: { firstName: '', email: '', notifications: false, tags: [], }, }); return ( {/* Text field */} ( )} /> {/* Email field */} ( )} /> {/* Select */} ( Role {errors.role && {errors.role.message}} )} /> {/* Autocomplete (multi) */} ( onChange(newValue)} renderInput={(params) => ( )} /> )} /> {/* Checkbox */} ( onChange(e.target.checked)} /> } label="Receive email notifications" /> )} /> Save ); } ``` ### useFieldArray for dynamic lists ```tsx import { useFieldArray } from 'react-hook-form'; const { fields, append, remove } = useFieldArray({ control, name: 'items' }); {fields.map((field, index) => ( } /> remove(index)}> ))} ``` --- ## Formik + MUI Integration ```tsx import { Formik, Form } from 'formik'; import * as Yup from 'yup'; const validationSchema = Yup.object({ name: Yup.string().required('Name is required'), email: Yup.string().email('Invalid email').required('Email is required'), age: Yup.number().min(18, 'Must be 18 or older').required('Age is required'), }); function FormikForm() { return ( { await submitData(values); setSubmitting(false); }} > {({ values, errors, touched, handleChange, handleBlur, isSubmitting }) => (
)}
); } ``` --- ## Multi-Step Form with Stepper ```tsx import Stepper from '@mui/material/Stepper'; import Step from '@mui/material/Step'; import StepLabel from '@mui/material/StepLabel'; const STEPS = ['Personal info', 'Address', 'Review']; function MultiStepForm() { const [activeStep, setActiveStep] = React.useState(0); const [formData, setFormData] = React.useState({ personal: { name: '', email: '' }, address: { street: '', city: '', zip: '' }, }); const handleNext = (stepData: object) => { setFormData((prev) => ({ ...prev, ...stepData })); setActiveStep((s) => s + 1); }; const handleBack = () => setActiveStep((s) => s - 1); return ( {STEPS.map((label) => ( {label} ))} {activeStep === 0 && ( )} {activeStep === 1 && ( )} {activeStep === 2 && ( )} {activeStep === STEPS.length && ( All done! )} ); } ``` --- ## Form Accessibility ```tsx // Always use htmlFor on labels or the label prop on TextField Bio Maximum 200 characters // Group related fields with fieldset + legend Delivery preference } label="Standard (5-7 days)" /> } label="Express (2-3 days)" /> // Announce validation errors to screen readers {emailError && ( {emailError} )} // Use noValidate on form to suppress browser native validation bubbles ``` --- ## React Hook Form + Zod (Modern Stack) The recommended modern approach: type-safe, minimal re-renders. ```bash npm install react-hook-form @hookform/resolvers zod ``` ### Complete Form with Zod Schema ```tsx import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import TextField from '@mui/material/TextField'; import Select from '@mui/material/Select'; import MenuItem from '@mui/material/MenuItem'; import FormControl from '@mui/material/FormControl'; import InputLabel from '@mui/material/InputLabel'; import FormHelperText from '@mui/material/FormHelperText'; import Button from '@mui/material/Button'; import Stack from '@mui/material/Stack'; const userSchema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters'), email: z.string().email('Invalid email address'), role: z.enum(['admin', 'editor', 'viewer'], { required_error: 'Please select a role', }), age: z.coerce.number().min(18, 'Must be 18+').max(120), bio: z.string().max(500, 'Bio must be under 500 characters').optional(), password: z.string() .min(8, 'Password must be at least 8 characters') .regex(/[A-Z]/, 'Must contain an uppercase letter') .regex(/[0-9]/, 'Must contain a number'), confirmPassword: z.string(), }).refine((data) => data.password === data.confirmPassword, { message: 'Passwords do not match', path: ['confirmPassword'], }); type UserFormData = z.infer; function UserForm({ onSubmit }: { onSubmit: (data: UserFormData) => void }) { const { control, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(userSchema), defaultValues: { name: '', email: '', role: undefined, bio: '' }, }); return ( ( )} /> ( )} /> ( Role {errors.role && {errors.role.message}} )} /> ( )} /> ); } ``` ### Controller Pattern for MUI Components `Controller` is needed for MUI components because they don't use native HTML inputs: ```tsx // DatePicker with Controller ( )} /> // Autocomplete with Controller ( onChange(newValue)} renderInput={(params) => ( )} /> )} /> // Switch with Controller ( } label="Enable notifications" /> )} /> ``` ### Multi-Step Form with Stepper ```tsx const stepSchemas = [ z.object({ name: z.string().min(1), email: z.string().email() }), z.object({ address: z.string().min(1), city: z.string().min(1) }), z.object({ cardNumber: z.string().regex(/^\d{16}$/) }), ]; function MultiStepForm() { const [step, setStep] = useState(0); const [formData, setFormData] = useState({}); const currentSchema = stepSchemas[step]; const { control, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(currentSchema), defaultValues: formData, }); const onStepSubmit = (data: any) => { const merged = { ...formData, ...data }; setFormData(merged); if (step < stepSchemas.length - 1) { setStep(step + 1); } else { submitFinalForm(merged); } }; return ( <> Account Address Payment {step === 0 && } {step === 1 && } {step === 2 && } {step > 0 && } ); } ``` ### Conditional Validation ```tsx const schema = z.discriminatedUnion('accountType', [ z.object({ accountType: z.literal('personal'), name: z.string().min(1), }), z.object({ accountType: z.literal('business'), name: z.string().min(1), companyName: z.string().min(1), taxId: z.string().regex(/^\d{9}$/, 'Tax ID must be 9 digits'), }), ]); ``` ### Server-Side Validation Errors ```tsx const { setError, handleSubmit } = useForm({ resolver: zodResolver(schema), }); const onSubmit = async (data: FormData) => { try { await api.createUser(data); } catch (err) { if (err.response?.status === 422) { // Map server errors to form fields const serverErrors = err.response.data.errors; Object.entries(serverErrors).forEach(([field, message]) => { setError(field as keyof FormData, { message: message as string }); }); } } }; ```