forms-validation
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.
function ControlledForm() {
const [name, setName] = React.useState('');
const [email, setEmail] = React.useState('');
const [error, setError] = React.useState<string | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email.includes('@')) {
setError('Enter a valid email address');
return;
}
// submit...
};
return (
<Box component="form" onSubmit={handleSubmit} noValidate>
<TextField
label="Full name"
value={name}
onChange={(e) => setName(e.target.value)}
fullWidth
margin="normal"
/>
<TextField
label="Email"
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError(null); // clear error on change
}}
error={!!error}
helperText={error}
fullWidth
margin="normal"
/>
<Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
Submit
</Button>
</Box>
);
}
Uncontrolled with refs
Use for very large forms where performance matters, or when integrating with non-React code.
function UncontrolledForm() {
const nameRef = React.useRef<HTMLInputElement>(null);
const emailRef = React.useRef<HTMLInputElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data = {
name: nameRef.current?.value,
email: emailRef.current?.value,
};
// submit data
};
return (
<Box component="form" onSubmit={handleSubmit}>
<TextField label="Name" inputRef={nameRef} fullWidth margin="normal" />
<TextField label="Email" type="email" inputRef={emailRef} fullWidth margin="normal" />
<Button type="submit" variant="contained">Submit</Button>
</Box>
);
}
TextField Error and Helper Text Patterns
// error flag turns label and border red; helperText shows message below
<TextField
label="Password"
type="password"
error={password.length > 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
<TextField
label="Bio"
multiline
rows={3}
value={bio}
onChange={(e) => 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.
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<HTMLInputElement>) => {
const updated = { ...prefs, [e.target.name]: e.target.checked };
setPrefs(updated);
setError(!Object.values(updated).some(Boolean)); // at least one required
};
return (
<FormControl error={error} component="fieldset" variant="standard">
<FormLabel component="legend">Notification channels</FormLabel>
<FormGroup>
<FormControlLabel
control={<Checkbox checked={prefs.email} onChange={handleChange} name="email" />}
label="Email notifications"
/>
<FormControlLabel
control={<Checkbox checked={prefs.sms} onChange={handleChange} name="sms" />}
label="SMS notifications"
/>
<FormControlLabel
control={<Checkbox checked={prefs.push} onChange={handleChange} name="push" />}
label="Push notifications"
/>
</FormGroup>
{error && <FormHelperText>Select at least one notification channel.</FormHelperText>}
</FormControl>
);
}
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.
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<typeof schema>;
function UserForm({ onSubmit }: { onSubmit: (data: FormValues) => void }) {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
firstName: '',
email: '',
notifications: false,
tags: [],
},
});
return (
<Box component="form" onSubmit={handleSubmit(onSubmit)} noValidate>
<Stack spacing={2}>
{/* Text field */}
<Controller
name="firstName"
control={control}
render={({ field }) => (
<TextField
{...field}
label="First name"
error={!!errors.firstName}
helperText={errors.firstName?.message}
fullWidth
/>
)}
/>
{/* Email field */}
<Controller
name="email"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Email"
type="email"
error={!!errors.email}
helperText={errors.email?.message}
fullWidth
/>
)}
/>
{/* Select */}
<Controller
name="role"
control={control}
render={({ field }) => (
<FormControl error={!!errors.role} fullWidth>
<InputLabel>Role</InputLabel>
<Select {...field} label="Role">
<MenuItem value="admin">Admin</MenuItem>
<MenuItem value="editor">Editor</MenuItem>
<MenuItem value="viewer">Viewer</MenuItem>
</Select>
{errors.role && <FormHelperText>{errors.role.message}</FormHelperText>}
</FormControl>
)}
/>
{/* Autocomplete (multi) */}
<Controller
name="tags"
control={control}
render={({ field: { onChange, value } }) => (
<Autocomplete
multiple
options={availableTags}
value={value}
onChange={(_, newValue) => onChange(newValue)}
renderInput={(params) => (
<TextField
{...params}
label="Tags"
error={!!errors.tags}
helperText={errors.tags?.message}
/>
)}
/>
)}
/>
{/* Checkbox */}
<Controller
name="notifications"
control={control}
render={({ field: { onChange, value } }) => (
<FormControlLabel
control={
<Checkbox checked={value} onChange={(e) => onChange(e.target.checked)} />
}
label="Receive email notifications"
/>
)}
/>
<LoadingButton
type="submit"
variant="contained"
loading={isSubmitting}
fullWidth
>
Save
</LoadingButton>
</Stack>
</Box>
);
}
useFieldArray for dynamic lists
import { useFieldArray } from 'react-hook-form';
const { fields, append, remove } = useFieldArray({ control, name: 'items' });
{fields.map((field, index) => (
<Stack key={field.id} direction="row" spacing={1}>
<Controller
name={`items.${index}.value`}
control={control}
render={({ field: f }) => <TextField {...f} label={`Item ${index + 1}`} />}
/>
<IconButton onClick={() => remove(index)}><DeleteIcon /></IconButton>
</Stack>
))}
<Button onClick={() => append({ value: '' })} startIcon={<AddIcon />}>Add item</Button>
Formik + MUI Integration
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 (
<Formik
initialValues={{ name: '', email: '', age: '' }}
validationSchema={validationSchema}
onSubmit={async (values, { setSubmitting }) => {
await submitData(values);
setSubmitting(false);
}}
>
{({ values, errors, touched, handleChange, handleBlur, isSubmitting }) => (
<Form>
<Stack spacing={2}>
<TextField
name="name"
label="Full name"
value={values.name}
onChange={handleChange}
onBlur={handleBlur}
error={touched.name && Boolean(errors.name)}
helperText={touched.name && errors.name}
fullWidth
/>
<TextField
name="email"
label="Email"
type="email"
value={values.email}
onChange={handleChange}
onBlur={handleBlur}
error={touched.email && Boolean(errors.email)}
helperText={touched.email && errors.email}
fullWidth
/>
<TextField
name="age"
label="Age"
type="number"
value={values.age}
onChange={handleChange}
onBlur={handleBlur}
error={touched.age && Boolean(errors.age)}
helperText={touched.age && errors.age}
fullWidth
/>
<Button type="submit" variant="contained" disabled={isSubmitting} fullWidth>
{isSubmitting ? 'Saving...' : 'Save'}
</Button>
</Stack>
</Form>
)}
</Formik>
);
}
Multi-Step Form with Stepper
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 (
<Box sx={{ maxWidth: 600, mx: 'auto', py: 4 }}>
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
{STEPS.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
{activeStep === 0 && (
<PersonalInfoStep data={formData.personal} onNext={handleNext} />
)}
{activeStep === 1 && (
<AddressStep data={formData.address} onNext={handleNext} onBack={handleBack} />
)}
{activeStep === 2 && (
<ReviewStep data={formData} onBack={handleBack} onSubmit={handleFinalSubmit} />
)}
{activeStep === STEPS.length && (
<Box textAlign="center">
<CheckCircleIcon color="success" sx={{ fontSize: 64 }} />
<Typography variant="h5">All done!</Typography>
</Box>
)}
</Box>
);
}
Form Accessibility
// Always use htmlFor on labels or the label prop on TextField
<FormControl>
<FormLabel htmlFor="bio-input">Bio</FormLabel>
<OutlinedInput id="bio-input" multiline rows={3} aria-describedby="bio-helper" />
<FormHelperText id="bio-helper">Maximum 200 characters</FormHelperText>
</FormControl>
// Group related fields with fieldset + legend
<FormControl component="fieldset">
<FormLabel component="legend">Delivery preference</FormLabel>
<RadioGroup>
<FormControlLabel value="standard" control={<Radio />} label="Standard (5-7 days)" />
<FormControlLabel value="express" control={<Radio />} label="Express (2-3 days)" />
</RadioGroup>
</FormControl>
// Announce validation errors to screen readers
<TextField
inputProps={{
'aria-describedby': emailError ? 'email-error' : undefined,
'aria-invalid': !!emailError,
}}
error={!!emailError}
/>
{emailError && (
<FormHelperText id="email-error" error role="alert">
{emailError}
</FormHelperText>
)}
// Use noValidate on form to suppress browser native validation bubbles
<Box component="form" noValidate onSubmit={handleSubmit}>
React Hook Form + Zod (Modern Stack)
The recommended modern approach: type-safe, minimal re-renders.
npm install react-hook-form @hookform/resolvers zod
Complete Form with Zod Schema
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<typeof userSchema>;
function UserForm({ onSubmit }: { onSubmit: (data: UserFormData) => void }) {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: { name: '', email: '', role: undefined, bio: '' },
});
return (
<Stack component="form" onSubmit={handleSubmit(onSubmit)} spacing={2} noValidate>
<Controller
name="name"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Full Name"
error={!!errors.name}
helperText={errors.name?.message}
fullWidth
required
/>
)}
/>
<Controller
name="email"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Email"
type="email"
error={!!errors.email}
helperText={errors.email?.message}
fullWidth
required
/>
)}
/>
<Controller
name="role"
control={control}
render={({ field }) => (
<FormControl fullWidth error={!!errors.role}>
<InputLabel id="role-label">Role</InputLabel>
<Select {...field} labelId="role-label" label="Role">
<MenuItem value="admin">Administrator</MenuItem>
<MenuItem value="editor">Editor</MenuItem>
<MenuItem value="viewer">Viewer</MenuItem>
</Select>
{errors.role && <FormHelperText>{errors.role.message}</FormHelperText>}
</FormControl>
)}
/>
<Controller
name="age"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Age"
type="number"
error={!!errors.age}
helperText={errors.age?.message}
/>
)}
/>
<Button type="submit" variant="contained" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save'}
</Button>
</Stack>
);
}
Controller Pattern for MUI Components
Controller is needed for MUI components because they don't use native HTML inputs:
// DatePicker with Controller
<Controller
name="startDate"
control={control}
render={({ field, fieldState: { error } }) => (
<DatePicker
{...field}
label="Start Date"
slotProps={{
textField: {
error: !!error,
helperText: error?.message,
},
}}
/>
)}
/>
// Autocomplete with Controller
<Controller
name="tags"
control={control}
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
<Autocomplete
{...field}
multiple
options={allTags}
value={value || []}
onChange={(_, newValue) => onChange(newValue)}
renderInput={(params) => (
<TextField
{...params}
label="Tags"
error={!!error}
helperText={error?.message}
/>
)}
/>
)}
/>
// Switch with Controller
<Controller
name="notifications"
control={control}
render={({ field }) => (
<FormControlLabel
control={<Switch {...field} checked={field.value} />}
label="Enable notifications"
/>
)}
/>
Multi-Step Form with Stepper
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 (
<>
<Stepper activeStep={step} alternativeLabel>
<Step><StepLabel>Account</StepLabel></Step>
<Step><StepLabel>Address</StepLabel></Step>
<Step><StepLabel>Payment</StepLabel></Step>
</Stepper>
<Box component="form" onSubmit={handleSubmit(onStepSubmit)} sx={{ mt: 3 }}>
{step === 0 && <AccountFields control={control} errors={errors} />}
{step === 1 && <AddressFields control={control} errors={errors} />}
{step === 2 && <PaymentFields control={control} errors={errors} />}
<Box sx={{ display: 'flex', gap: 1, mt: 2 }}>
{step > 0 && <Button onClick={() => setStep(step - 1)}>Back</Button>}
<Button type="submit" variant="contained">
{step === stepSchemas.length - 1 ? 'Submit' : 'Next'}
</Button>
</Box>
</Box>
</>
);
}
Conditional Validation
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
const { setError, handleSubmit } = useForm<FormData>({
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 });
});
}
}
};
More from lobbi-docs/claude
vision-multimodal
Vision and multimodal capabilities for Claude including image analysis, PDF processing, and document understanding. Activate for image input, base64 encoding, multiple images, and visual analysis.
242design-system
Apply and manage the AI-powered design system with 50+ curated styles
126complex-reasoning
Multi-step reasoning patterns and frameworks for systematic problem solving. Activate for Chain-of-Thought, Tree-of-Thought, hypothesis-driven debugging, and structured analytical approaches that leverage extended thinking.
105gcp
Google Cloud Platform services including GKE, Cloud Run, Cloud Storage, BigQuery, and Pub/Sub. Activate for GCP infrastructure, Google Cloud deployment, and GCP integration.
73kanban
Kanban methodology including boards, WIP limits, flow metrics, and continuous delivery. Activate for Kanban boards, workflow visualization, and lean project management.
62debugging
Debugging techniques for Python, JavaScript, and distributed systems. Activate for troubleshooting, error analysis, log investigation, and performance debugging. Includes extended thinking integration for complex debugging scenarios.
59