Give the model a Zod schema and a named list of states — error, dirty, disabled, submitting, server-error — and it scaffolds the tedious 70% of form work flawlessly. Withhold the state list and you get the demo-quality happy path that falls over on its first failed submit.
Tier: Foundation · Workflow area: Component development · Tools: react-hook-form + Zod, GitHub Copilot, Claude
Forms are the highest-volume, most state-dense UI work in product engineering, which makes them ideal AI territory — provided you drive generation from a schema, not a vibe. Schema-first means validation, types, and UI all derive from one source of truth, so nothing drifts.
Write the Zod schema first, by hand, with the server's rules in it. This is the step that keeps you in charge:
export const inviteSchema = z.object({
email: z.string().email('Enter a valid work email'),
role: z.enum(['viewer', 'editor', 'admin']),
teamIds: z.array(z.string().uuid()).min(1, 'Pick at least one team'),
message: z.string().max(280, 'Keep it under 280 characters').optional(),
});
export type InviteInput = z.infer<typeof inviteSchema>;
Prompt with the schema plus the full state enumeration. The enumeration is the payload of this playbook — paste it every time (or bake it into your instruction file):
Scaffold an InviteMemberForm using react-hook-form + zodResolver and the
attached inviteSchema. Use @acme/ui components only (TextField, Select,
MultiSelect, Textarea, Button, Banner). Implement EVERY state:
1. pristine — no errors shown, submit enabled
2. field error — on blur + on submit, message under the field, input
gets aria-invalid and aria-describedby pointing at the message
3. dirty/unsaved — "Discard changes?" guard on navigation
4. submitting — submit disabled with spinner, all fields disabled
5. server field-error — map 422 {errors: {email: "..."}} onto the
matching fields via setError
6. server global-error — Banner above the form with retry, form stays
filled
7. success — call onSuccess(invite), reset the form
Submit handler calls inviteMember(input): Promise<Result>. No fetch here.
Inspect for the three classic scaffold bugs before running anything. A good output shows: mode: 'onBlur', disabled={isSubmitting} on every input and the button, and a setError loop for server errors. The classic bugs to catch: validation firing on every keystroke from first render, the submit button disabled merely because the form is pristine-invalid (traps keyboard users out of discovering errors), and server errors rendered only as a toast that vanishes.
Generate the unhappy-path tests, then hand-verify the two worst ones. Ask for behavior-first tests (see pb-22):
Write RTL + Vitest tests for InviteMemberForm covering: invalid email
blur shows the message and wires aria-describedby; submit with server
422 maps the error to the email field; global 500 shows the Banner and
preserves entered values; double-click submit fires inviteMember once.
npx vitest run src/features/members/InviteMemberForm.test.tsx
Then verify by hand in the browser: the 422 path and the double-submit path. Mock the failure at the network layer (MSW, pb-07) and click around like an annoyed user.
Wire the schema to the server contract so drift is impossible. If the API has an OpenAPI/GraphQL schema, derive or cross-check the Zod schema from it rather than duplicating rules:
npx openapi-zod-client ./openapi.json -o src/api/zod-schemas.ts
One schema, three consumers: client validation, form types, request typing.
Accessibility is part of "every state," not a follow-up. Field errors must be programmatically associated (aria-describedby), the global error Banner should be a live region, and focus should move to the first invalid field on a failed submit. Put these in the prompt — generated forms only include them when asked.
@hookform/resolvers/zod; for Next.js server actions, keep the same schema and run inviteSchema.safeParse on the server too — never trust the client copy alone.@vee-validate/zod (or zod + composable): same prompt structure, swap the library names and ask for useForm({ validationSchema: toTypedSchema(inviteSchema) }); the seven-state enumeration transfers unchanged.FormGroup from the schema shape and a zodValidator bridge, states via form.status, dirty, and a server-error mapper into setErrors.required attributes or inline regexes.aria-invalid, aria-describedby, focus-on-error verified with a keyboard-only pass.The model is excellent at form plumbing and terrible at deciding which states matter — so you own the schema and the seven-state enumeration, it owns the scaffold, and the unhappy paths get hand-verified every single time.