| name | forms |
| version | 1.0.0 |
| description | Forms with React Hook Form + Zod. Use this skill whenever the user is building a form, adding a form field, writing a Zod schema for a form, creating a form hook, wiring a Server Action to a form, or handling form validation. Trigger when the user creates a new form component, asks how to validate a field, wants to handle form submission, or needs to show server-side errors. Do NOT trigger for general Zod schema questions unrelated to forms. |
Forms — React Hook Form + Zod
Every form in this project MUST use React Hook Form + Zod. No exceptions.
Required Stack
| Concern | Tool | Location |
|---|
| Form state & submission | React Hook Form | Custom hook |
| Validation schema | Zod | [entity].schema.ts |
| Schema → RHF bridge | @hookform/resolvers/zod | Custom hook |
| Error messages | validation-messages.ts | Per domain |
| Form submission | Server Action | actions.ts |
File Structure (per form)
domains/[entity]/
├── [form-name].schema.ts ← Zod schema + inferred type
├── hooks/
│ └── use-[form-name]-submit.ts ← useForm, resolver, onSubmit
├── components/organisms/
│ └── [form-name]-form.tsx ← purely presentational
└── actions.ts ← Server Action with server-side revalidation
1. Schema ([form-name].schema.ts)
import { z } from 'zod';
import { authValidationMessages } from './validation-messages';
export const loginSchema = z.object({
email: z.string().email(authValidationMessages.email),
password: z.string().min(8, authValidationMessages.passwordTooShort)
});
export type LoginInput = z.infer<typeof loginSchema>;
Rules:
- One schema per file — never mix unrelated schemas
- Export both the schema constant and the inferred
Input type
- Error messages from
validation-messages.ts — no hardcoded strings
- Never use
any or bypass .safeParse() type narrowing
2. Hook (use-[form-name]-submit.ts)
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema, type LoginInput } from '../login.schema';
import { loginAction } from '../actions';
export function useLoginSubmit() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError
} = useForm<LoginInput>({
resolver: zodResolver(loginSchema)
});
const onSubmit = handleSubmit(async data => {
const result = await loginAction(data);
if (result?.error) {
setError('root', { message: result.error });
}
});
return { register, onSubmit, errors, isSubmitting };
}
Rules:
- Always pass
resolver: zodResolver(schema) to useForm
- Type
useForm<SchemaInput> explicitly — never infer any
- Wrap submission with
handleSubmit — never call directly
- Map server errors back via
setError('root', ...)
- Never call Server Actions directly from a component
3. Form Component ([form-name]-form.tsx)
'use client';
import { useLoginSubmit } from '../../hooks/use-login-submit';
import { authMessages } from '../../messages';
export function LoginForm() {
const { register, onSubmit, errors, isSubmitting } = useLoginSubmit();
return (
<form onSubmit={onSubmit} className="login-form">
<div className="login-form__field">
<label htmlFor="email">{authMessages.login.emailLabel}</label>
<input id="email" type="email" {...register('email')} />
{errors.email && (
<span className="login-form__error">{errors.email.message}</span>
)}
</div>
<div className="login-form__field">
<label htmlFor="password">{authMessages.login.passwordLabel}</label>
<input id="password" type="password" {...register('password')} />
{errors.password && (
<span className="login-form__error">{errors.password.message}</span>
)}
</div>
{errors.root && (
<p className="login-form__root-error">{errors.root.message}</p>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting
? authMessages.login.submitting
: authMessages.login.submit}
</button>
</form>
);
}
Rules:
- Form component only renders — all logic lives in the hook
- Use
{...register('fieldName')} for every input
- Always render
errors.{field}.message beside each field
- Always handle
errors.root for server-side errors
- Disable submit button while
isSubmitting
- Never use
useState to track field values — RHF owns state
- Never use
e.preventDefault() — handleSubmit does it
4. Server Action (actions.ts)
'use server';
import { loginSchema, type LoginInput } from './login.schema';
export async function loginAction(input: LoginInput) {
const validated = loginSchema.safeParse(input);
if (!validated.success) {
return { error: 'Invalid input' };
}
const { email, password } = validated.data;
}
Rules:
- Always revalidate on the server with
schema.safeParse() — never trust client validation alone
- Return
{ error: string } for errors the hook can pass to setError('root', ...)
Multi-Step Forms
Split by step. Compose with .merge() for server validation.
export const step1Schema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1)
});
export const step2Schema = z.object({
role: z.enum(['admin', 'member']),
team: z.string().min(1)
});
export const onboardingSchema = step1Schema.merge(step2Schema);
export type OnboardingInput = z.infer<typeof onboardingSchema>;
Anti-Patterns
const [email, setEmail] = useState('');
if (!email.includes('@')) { setError('Invalid email'); }
const schema = z.object({ email: z.string() });
<form action={loginAction}>
// ❌ useForm without resolver
useForm(); // validation won't run
// ❌ Hardcoded error messages in schema
z.string().email('Please enter a valid email') // must be in validation-messages.ts
Pre-Submit Checklist