Cursor generates dynamic React form validation reliably when you specify the validation library, error message shape, and trigger events in your prompt. Use .cursorrules to lock in react-hook-form plus Zod as defaults, then use Chat or Composer to scaffold complete form components with per-field real-time validation, accessible error messages, and typed submit handlers — ready to drop into any React 18 project.
Generate dynamic React form validation with Cursor
Form validation is one of the most repetitive tasks in React development. Without guidance, Cursor tends to produce uncontrolled inputs with ad-hoc useState checks that miss edge cases — password confirmation mismatches, async username availability, and conditional required fields all require a proper schema-driven approach. This tutorial configures Cursor to use react-hook-form with Zod schemas, producing validation that is declarative, type-safe, and easy to extend. You will build a registration form step by step, learning exactly which Cursor prompts produce the best results.
Prerequisites
- Cursor installed (Free plan or above)
- React 18 project with TypeScript
- react-hook-form and zod installed: npm install react-hook-form zod @hookform/resolvers
- Basic understanding of Zod schemas is helpful but not required
Step-by-step guide
Configure .cursorrules for form validation defaults
Configure .cursorrules for form validation defaults
Add a form validation section to .cursorrules. This tells Cursor which libraries to use for every form-related prompt, eliminating the need to repeat library preferences. Include rules for error message placement (below each field), trigger mode (onChange for immediate feedback), and TypeScript inference from Zod schemas using z.infer. Save the file before proceeding.
1# .cursorrules23## React Forms4- Always use react-hook-form (v7) with @hookform/resolvers/zod for form state management.5- Always define validation schemas with Zod. Derive TypeScript types using z.infer<typeof schema>.6- Use mode: 'onChange' in useForm to show validation errors immediately as the user types.7- Place error messages directly below each input as a <p className="text-red-500 text-sm"> element.8- NEVER use uncontrolled inputs without react-hook-form register.9- NEVER use yup or manual useState validation — use Zod schemas only.10- Form submit handlers must be typed: handleSubmit accepts (data: FormData) => Promise<void>Pro tip: Adding 'NEVER use uncontrolled inputs without react-hook-form register' is important — Cursor sometimes falls back to raw useState for simple-looking forms.
Expected result: Cursor will default to react-hook-form + Zod for all future form-related prompts in this project.
Generate a Zod schema and typed form component
Generate a Zod schema and typed form component
Open Cursor Chat (Cmd+L) and request a full registration form. Include the field list, validation rules, and file path in the prompt. Cursor will generate the Zod schema, derive the TypeScript type, set up useForm with the Zod resolver, and wire register to each input. The more specific your field requirements, the better the output — list each field's constraints explicitly.
1Generate a React registration form at src/components/RegistrationForm.tsx.2Fields and validation rules:3- email: required, valid email format4- password: required, min 8 chars, at least one uppercase letter5- confirmPassword: required, must match password field exactly6- username: required, min 3 chars, max 20 chars, alphanumeric only78Requirements:9- Zod schema with z.infer for TypeScript type10- useForm with zodResolver, mode: 'onChange'11- Each field shows its error message below the input12- Submit button disabled while form is invalid or submitting13- onSubmit prop typed as (data: RegistrationFormData) => Promise<void>Pro tip: Ask for the Zod schema and the component in the same prompt. Cursor produces cleaner code when both are in one file rather than split across imports.
Expected result: Cursor creates RegistrationForm.tsx with a Zod schema, typed form data interface, and all four fields with inline validation messages.
Add cross-field validation with Zod refine
Add cross-field validation with Zod refine
Select the Zod schema block in the file and press Cmd+K for an inline edit. Ask Cursor to add the password-confirmation cross-field check using Zod's .refine method. Cross-field validation cannot be expressed at the field level in Zod — it must be applied to the entire object schema. Cursor handles this correctly when the instruction is explicit about using .refine at the schema level, not on individual fields.
1Extend the registrationSchema with a .refine check:2- Condition: data.password === data.confirmPassword3- Error message: 'Passwords do not match'4- Path: ['confirmPassword'] so the error appears on the confirmPassword field56Also add a second .refine for the username:7- Async check: call checkUsernameAvailable(username) which returns Promise<boolean>8- If false, error: 'Username is already taken'9- Place checkUsernameAvailable as an imported stub in src/api/auth.tsPro tip: Zod's async .refine runs on schema.parseAsync — make sure Cursor updates the resolver accordingly. If it doesn't, add 'ensure the resolver uses parseAsync for async refinements' to your prompt.
Expected result: Schema now has two refine checks. The form shows 'Passwords do not match' on the confirmPassword field and validates username availability asynchronously.
Add conditional required fields
Add conditional required fields
Open Composer (Cmd+I) and add a 'company name' field that is only required when the user selects 'Business' from an account type dropdown. Cursor needs to see the current form file to understand the existing schema structure — reference it with @file. Zod handles conditional validation with .superRefine, which gives access to the full object and the refinement context. Ask Cursor to also watch the accountType field and show or hide the company input reactively.
1Using @file src/components/RegistrationForm.tsx:23Add an 'accountType' select field (options: 'Personal' | 'Business') and a 'companyName' text field.4- companyName is required only when accountType === 'Business'5- Use Zod .superRefine to add the conditional validation to registrationSchema6- In the JSX, conditionally render the companyName input using watch('accountType') === 'Business'7- Show 'Company name is required for business accounts' as the error messagePro tip: Use watch() from react-hook-form to reactively show/hide fields. Cursor knows this pattern — just mention 'conditionally render based on watch()' in your prompt.
Expected result: Form now shows the company name field only when Business is selected. Submitting without it while Business is selected shows the validation error.
Wire the form to an API and handle server-side errors
Wire the form to an API and handle server-side errors
Open Cursor Chat and ask it to update the onSubmit handler to call a real API endpoint, handle the server's error response shape, and display field-level server errors using setError from react-hook-form. Server-side validation errors (duplicate email, banned username) need to map back to specific form fields — Cursor can generate this mapping when you provide the API response type.
1Update the RegistrationForm onSubmit to call POST /api/register with the form data.2API error response shape: { field: keyof RegistrationFormData; message: string }[]34On success: call props.onSuccess(data)5On 4xx error: parse the JSON body, loop through errors, and call setError(error.field, { message: error.message }) for each6On network error: call setError('root', { message: 'Network error — please try again' })7Display root errors above the submit buttonExpected result: Submitting the form calls the API. Server-side field errors appear on the correct inputs. A root error message handles network failures.
Complete working example
1import { useForm } from 'react-hook-form';2import { zodResolver } from '@hookform/resolvers/zod';3import { z } from 'zod';45const registrationSchema = z6 .object({7 email: z.string().email('Enter a valid email address'),8 password: z9 .string()10 .min(8, 'Password must be at least 8 characters')11 .regex(/[A-Z]/, 'Password must contain at least one uppercase letter'),12 confirmPassword: z.string().min(1, 'Please confirm your password'),13 username: z14 .string()15 .min(3, 'Username must be at least 3 characters')16 .max(20, 'Username must be 20 characters or fewer')17 .regex(/^[a-zA-Z0-9]+$/, 'Username can only contain letters and numbers'),18 accountType: z.enum(['Personal', 'Business']),19 companyName: z.string().optional(),20 })21 .refine((data) => data.password === data.confirmPassword, {22 message: 'Passwords do not match',23 path: ['confirmPassword'],24 })25 .superRefine((data, ctx) => {26 if (data.accountType === 'Business' && !data.companyName) {27 ctx.addIssue({28 code: z.ZodIssueCode.custom,29 message: 'Company name is required for business accounts',30 path: ['companyName'],31 });32 }33 });3435type RegistrationFormData = z.infer<typeof registrationSchema>;3637interface RegistrationFormProps {38 onSuccess: (data: RegistrationFormData) => void;39}4041export function RegistrationForm({ onSuccess }: RegistrationFormProps) {42 const {43 register,44 handleSubmit,45 watch,46 setError,47 formState: { errors, isSubmitting, isValid },48 } = useForm<RegistrationFormData>({49 resolver: zodResolver(registrationSchema),50 mode: 'onChange',51 defaultValues: { accountType: 'Personal' },52 });5354 const accountType = watch('accountType');5556 const onSubmit = async (data: RegistrationFormData) => {57 try {58 const res = await fetch('/api/register', {59 method: 'POST',60 headers: { 'Content-Type': 'application/json' },61 body: JSON.stringify(data),62 });63 if (!res.ok) {64 const errs: { field: keyof RegistrationFormData; message: string }[] =65 await res.json();66 errs.forEach(({ field, message }) => setError(field, { message }));67 return;68 }69 onSuccess(data);70 } catch {71 setError('root', { message: 'Network error — please try again' });72 }73 };7475 return (76 <form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md">77 {errors.root && (78 <p className="text-red-500 text-sm">{errors.root.message}</p>79 )}80 <div>81 <input {...register('email')} placeholder="Email" className="input" />82 {errors.email && (83 <p className="text-red-500 text-sm">{errors.email.message}</p>84 )}85 </div>86 <div>87 <input88 {...register('password')}89 type="password"90 placeholder="Password"91 className="input"92 />93 {errors.password && (94 <p className="text-red-500 text-sm">{errors.password.message}</p>95 )}96 </div>97 <div>98 <input99 {...register('confirmPassword')}100 type="password"101 placeholder="Confirm password"102 className="input"103 />104 {errors.confirmPassword && (105 <p className="text-red-500 text-sm">{errors.confirmPassword.message}</p>106 )}107 </div>108 <div>109 <input110 {...register('username')}111 placeholder="Username"112 className="input"113 />114 {errors.username && (115 <p className="text-red-500 text-sm">{errors.username.message}</p>116 )}117 </div>118 <select {...register('accountType')} className="input">119 <option value="Personal">Personal</option>120 <option value="Business">Business</option>121 </select>122 {accountType === 'Business' && (123 <div>124 <input125 {...register('companyName')}126 placeholder="Company name"127 className="input"128 />129 {errors.companyName && (130 <p className="text-red-500 text-sm">{errors.companyName.message}</p>131 )}132 </div>133 )}134 <button type="submit" disabled={!isValid || isSubmitting}>135 {isSubmitting ? 'Registering...' : 'Register'}136 </button>137 </form>138 );139}Common mistakes when handling Form Validation with Cursor
Why it's a problem: Prompting Cursor for 'form validation' without specifying react-hook-form and Zod
How to avoid: Always name the libraries explicitly in your prompt and add them to .cursorrules so the preference is permanent across all sessions.
Why it's a problem: Putting cross-field validation (password match) on an individual Zod field
How to avoid: Apply .refine or .superRefine to the top-level object schema and set the path option to point the error at the correct field.
Why it's a problem: Using mode: 'onSubmit' instead of mode: 'onChange' when asking for real-time validation
How to avoid: Include 'mode: onChange for real-time field validation' in your Cursor prompt. Add it to .cursorrules so it applies by default.
Why it's a problem: Forgetting to handle server-side validation errors from the API response
How to avoid: Always ask Cursor to generate both client-side and server-side error handling in the same prompt, mapping API error fields to setError calls.
Best practices
- Define Zod schemas in a separate src/schemas/ file and import them into form components — this lets you reuse the same schema on the server for API validation.
- Use z.infer<typeof schema> to derive the TypeScript type from the schema rather than maintaining a separate interface that can drift out of sync.
- Add the mode: 'onChange' and resolver: zodResolver(schema) to .cursorrules so you never need to repeat these in individual prompts.
- For async validations (username availability), debounce the API call using useDebounce to avoid hammering the server on every keystroke.
- Keep error messages in the Zod schema, not in JSX — this makes them easy to localise and keeps component code clean.
- Always test the form's disabled submit state: the button should be disabled while isSubmitting is true to prevent double-submissions.
- Reference the form component with @file when extending it in Cursor so the AI sees the existing schema and does not regenerate it from scratch.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
Write a React 18 TypeScript registration form using react-hook-form v7 and Zod. Fields: email (valid email), password (min 8 chars, one uppercase), confirmPassword (must match password), username (3-20 chars, alphanumeric). Use zodResolver, mode onChange. Show inline error messages below each field. Type the submit handler as (data: z.infer<typeof schema>) => Promise<void>.
In Cursor Chat (Cmd+L), type: 'Generate a React registration form at src/components/RegistrationForm.tsx with Zod schema and react-hook-form zodResolver. Fields: email (valid email), password (min 8 + one uppercase), confirmPassword (must match password via .refine), username (3-20 alphanumeric). mode: onChange. Inline error messages below each field. Typed onSubmit prop. Named export.'
Frequently asked questions
Can I use Yup instead of Zod with these Cursor prompts?
Yes. Replace 'Zod' with 'Yup' in your prompt and .cursorrules. The @hookform/resolvers package supports both. Zod is preferred because it provides TypeScript inference via z.infer, which eliminates the need for a separate type definition.
How do I get Cursor to generate a multi-step wizard form?
Open Composer (Cmd+I) and prompt: 'Convert RegistrationForm.tsx into a 3-step wizard. Step 1: email and username. Step 2: password and confirmPassword. Step 3: account type and company name. Validate each step on Next click using trigger() from react-hook-form. Show a progress indicator.' Reference the current file with @file.
Why does Cursor keep adding value and onChange props manually instead of using register?
This happens when Cursor generates a controlled input pattern from scratch instead of using react-hook-form's register. Add 'Always use the register spread from useForm — never use value/onChange props directly' to .cursorrules.
Can Cursor generate accessible form markup with ARIA attributes?
Yes. Add 'Each input must have an associated <label> and an aria-describedby attribute pointing to its error message element' to your prompt. Cursor will generate the id and htmlFor attributes to link labels, inputs, and error messages correctly.
How do I prevent Cursor from regenerating the Zod schema when I extend the form?
Reference the existing file with @file src/components/RegistrationForm.tsx in your Composer prompt. Cursor will read the existing schema and extend it with new fields rather than regenerating everything from scratch.
Does this approach work with React Native forms?
react-hook-form and Zod both work in React Native. Replace HTML inputs with TextInput components and remove Tailwind class names. Add 'This is a React Native project — use TextInput, Text, and View instead of HTML elements' to your Cursor prompt.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation