Verified against Claude Code · 2026-07-24
Build a multi-step form where client feedback and real validation never disagree
Wires React Hook Form to a Zod schema per step, with cross-field rules expressed at the schema level rather than scattered across handlers, so the instant client-side feedback and the actual submission validation are provably the same rules.
The prompt
Ready to copy — highlighted parts are example details you can swap.
You are building a multi-step form using React Hook Form with Zod for validation. The schema is the single source of truth for what counts as valid — the instant per-field feedback the user sees while typing and the validation that runs at actual submission time must be provably the same rules, not two hand-maintained copies that can drift apart. FORM STEPS 1) Account details (email, password, confirm password). 2) Company details (company name, size). 3) Billing (plan, start date, end date for a trial). FIELD LIST email (string, valid email), password (string, min 10 chars, one number), confirmPassword (string, must match password), companyName (string, required), planStartDate (date), planEndDate (date) CROSS-FIELD RULES confirmPassword must exactly match password; planEndDate must be strictly after planStartDate. SUBMIT BEHAVIOR On success, POST the combined payload and redirect to /welcome; on a server-side rejection (e.g., email already registered), show the error attached to the email field on step 1, even if the user is currently on step 3. IMPLEMENTATION RULES Define one Zod schema per step, and compose them into a single schema for final submission using a discriminated approach or .merge(), so a rule never has to be written twice for the per-step case and the whole-form case. Wire the schema to React Hook Form through zodResolver, and do not additionally hand-write field-level validate functions that duplicate a rule already expressed in the schema — every validation rule belongs in exactly one place. For cross-field rules — a confirmation field matching a password field, an end date that must fall after a start date — implement them with the schema's own .refine() or .superRefine(), attached to the specific field path via the second argument so the resulting error attaches to the right field and not to the form as a whole; a cross-field rule implemented instead as a scattered onChange handler comparing two field values manually is exactly the kind of duplicate logic this schema-first approach is meant to eliminate. Use React Hook Form's default uncontrolled-registration approach (register) for plain text and number fields to get its documented performance benefit of not re-rendering the whole form on every keystroke, and only use Controller for fields that genuinely need to be controlled — a third-party component that only accepts a value/onChange pair and has no ref-based API of its own. For the multi-step flow, validate only the current step's fields when the user clicks Next, using the appropriate schema slice, but validate the full combined schema at the final submission regardless of which step introduced a since-corrected error, so a user cannot bypass a rule by fixing it, going back, and resubmitting from a step where it now appears satisfied in isolation. OUTPUT FORMAT 1. The per-step Zod schemas and the composed full-form schema. 2. The form component wiring, showing register usage, any Controller usage and why it was needed there specifically, and the step-by-step validation trigger. 3. The cross-field rule implementation, shown as real code attached to the correct field path. 4. Confirmation that no validation rule exists in more than one place across the client feedback and the submission path.
Customize
Optional — swap in your own details for the highlighted parts above.
Why this works
Treating the Zod schema as the single source of truth, and explicitly forbidding a duplicate hand-written validate function for a rule the schema already expresses, closes the most common real-world bug in multi-step forms: the client-side feedback rule and the actual server-accepted rule quietly drift apart over a few sprints as someone tweaks one and forgets the other, and users start seeing a field marked valid on screen that the backend then rejects, or vice versa, with no code change ever intentionally introducing that disagreement. Requiring cross-field rules to live in .refine() or .superRefine() attached to the correct field path, rather than in a scattered onChange comparison, matters because Zod's schema-level refinement is what lets the exact same rule run identically whether it's triggered by live per-field feedback, a step transition, or the final submission — a handler-based comparison, by contrast, only runs when that specific handler fires, which means it can be silently skipped by a different code path (autofill, a paste event, a programmatic value set) that never triggers the handler at all. The register-versus-Controller distinction is a real, documented React Hook Form performance mechanism, not a style preference: register wires a field through an uncontrolled ref so most keystrokes never trigger a form-wide re-render, while Controller necessarily re-renders on every value change to keep a controlled component in sync, so defaulting every field to Controller — a common AI-generated pattern — silently reintroduces the exact per-keystroke re-render cost React Hook Form was chosen specifically to avoid. Composing per-step schemas with .merge() into one full-form schema, rather than writing the full-form schema by hand as a second document, is what actually guarantees the final-submission validation and the per-step validation can never fall out of sync with each other — since the full schema is derived from the same step schemas the per-step Next-button check already uses, a rule added to step one's schema is automatically part of the final validation with no second edit required, whereas two independently maintained schemas covering overlapping fields are exactly the kind of duplication this whole prompt is designed to eliminate in the first place.
What you get back
const step1Schema = z.object({ email: z.string().email(), password: z.string().min(10).regex(/\d/), confirmPassword: z.string(), }).refine(data => data.password === data.confirmPassword, { message: 'Passwords must match', path: ['confirmPassword'], }); const step3Schema = z.object({ planStartDate: z.coerce.date(), planEndDate: z.coerce.date(), }).refine(data => data.planEndDate > data.planStartDate, { message: 'End date must be after the start date', path: ['planEndDate'], }); const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema); const { register, trigger, formState: { errors } } = useForm({ resolver: zodResolver(fullSchema) }); // Next button on step 1: await trigger(['email', 'password', 'confirmPassword']) before advancing. // Final submit validates against fullSchema automatically via the resolver. No duplicate rules: password confirmation and date ordering exist only inside their respective .refine() calls — no matching onChange comparison exists anywhere else in the form component.
Verified against
Claude Code Sonnet 4.6 · 2026-07-24
Cursor Cursor 2.1 · 2026-08-01
Changelog
- 2026-07-24 — Initial publish, verified against Claude Code (Sonnet 4.6) and Cursor 2.1 using React Hook Form 7 and Zod 4.
Need this built into your business?
If a prompt isn't enough — custom software, built and maintained for you — that's Scult's day job.
EXPLORE CUSTOM SOFTWARE
