Agent Skills: MUI Forms and Validation

MUI form patterns with validation and library integration

UncategorizedID: lobbi-docs/claude/forms-validation

Install this agent skill to your local

pnpm dlx add-skill https://github.com/markus41/claude/tree/HEAD/plugins/mui-expert/skills/forms-validation

Skill Files

Browse the full folder contents for forms-validation.

Download Skill

Loading file tree…

plugins/mui-expert/skills/forms-validation/SKILL.md

Skill Metadata

Name
forms-validation
Description
MUI form patterns with validation and library integration

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 });
      });
    }
  }
};