Build forms with React Hook Form + Zod schema validation, accessible error display, and submission handling. Use when building or fixing forms with client-side validation, integrating Zod schemas with React Hook Form, handling async submission errors, or making forms keyboard and screen-reader accessible. Do not use for backend validation logic or state management without forms (prefer state-management).
Build accessible, validated forms using React Hook Form with Zod schema validation, proper error display, and submission handling.
@hookform/resolversstate-managementtailwind-shadcnz.object(), add constraints: z.string().min(1).email(), z.number().positive().useForm({ resolver: zodResolver(schema), defaultValues })register() for uncontrolled or Controller for controlled components. Always set id and htmlFor.formState.errors[field]?.message next to fields. Use aria-describedby linking error to input.handleSubmit(). Set loading state, catch server errors, display in form.<label htmlFor> with inputs, announce errors with aria-live="polite", set aria-invalid on errored fields.import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const schema = z.object({
email: z.string().min(1, 'Required').email('Invalid email'),
password: z.string().min(8, 'At least 8 characters'),
age: z.coerce.number().min(18, 'Must be 18+').optional(),
});
type FormData = z.infer<typeof schema>;
function SignupForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
});
const onSubmit = async (data: FormData) => {
await api.signup(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-err' : undefined}
{...register('email')} />
{errors.email && <p id="email-err" role="alert">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" type="password" aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'pw-err' : undefined}
{...register('password')} />
{errors.password && <p id="pw-err" role="alert">{errors.password.message}</p>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Sign Up'}
</button>
</form>
);
}
noValidate on <form> — let Zod handle validation, not the browser.mode: 'onBlur'.z.coerce.number() for numeric inputs — HTML inputs always return strings.aria-describedby and aria-invalid.react-typescript — typed component patternsaccessibility-audit — form accessibility testingstate-management — form state vs app state boundaries