Forms & Validation Expert Skill - A Claude Code skill recipe for advanced form handling with React Hook Form, Zod, and Server Actions
These skill recipes are designed for Claude Code but also work with other AI coding agents that support skill/instruction files.
Recipe
The complete SKILL.md content you can copy into .claude/skills/forms-validation/SKILL.md:
---
name: forms-validation-expert
description: "Advanced form handling with React Hook Form, Zod, and Server Actions. Use when asked to: form help, validation, Zod schema, form pattern, multi-step form, file upload form, react-hook-form, server action form, form errors."
allowed-tools: "Read, Write, Edit, Glob, Grep, Bash(npm:*), Bash(npx:*), Agent"
---
# Forms & Validation Expert
You are a forms and validation expert for React and Next.js. You build forms that are type-safe, accessible, progressively enhanced, and user-friendly.
## Validation Strategy Decision Tree
1. **Is this a Server Action form (no client JS required)?**
- Yes -> Zod validation in the Server Action + useActionState
- No -> Continue
2. **Does the form have complex client-side interactions?**
- Yes (conditional fields, multi-step, dynamic arrays) -> React Hook Form + Zod
- No (simple form, few fields) -> Server Action form or native HTML validation
3. **Are you using shadcn/ui?**
- Yes -> Use shadcn Form component (wraps React Hook Form)
- No -> Use React Hook Form directly
## Zod Schema Patterns
### Basic Schema
```tsx
import \{ z \} from "zod";
const UserSchema = z.object(\{
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Invalid email address"),
age: z.coerce.number().min(13, "Must be at least 13").max(120),
role: z.enum(["admin", "user", "moderator"]),
bio: z.string().max(500).optional(),
\});
type User = z.infer<typeof UserSchema>;Conditional Validation
const PaymentSchema = z.discriminatedUnion("method", [
z.object(\{
method: z.literal("card"),
cardNumber: z.string().regex(/^\d\{16\}$/, "Must be 16 digits"),
expiry: z.string().regex(/^\d\{2\}\/\d\{2\}$/, "MM/YY format"),
cvv: z.string().regex(/^\d\{3,4\}$/, "3 or 4 digits"),
\}),
z.object(\{
method: z.literal("paypal"),
paypalEmail: z.string().email(),
\}),
z.object(\{
method: z.literal("bank"),
accountNumber: z.string().min(8),
routingNumber: z.string().length(9),
\}),
]);Async Validation
const SignupSchema = z.object(\{
username: z
.string()
.min(3)
.refine(async (val) => \{
const exists = await checkUsernameExists(val);
return !exists;
\}, "Username is already taken"),
email: z.string().email(),
password: z.string().min(8),
confirmPassword: z.string(),
\}).refine((data) => data.password === data.confirmPassword, \{
message: "Passwords do not match",
path: ["confirmPassword"],
\});File Upload Validation
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
const UploadSchema = z.object(\{
avatar: z
.instanceof(File)
.refine((f) => f.size <= MAX_FILE_SIZE, "File must be under 5MB")
.refine(
(f) => ACCEPTED_TYPES.includes(f.type),
"Only JPEG, PNG, and WebP are accepted"
),
\});React Hook Form + Zod Pattern
"use client";
import \{ useForm \} from "react-hook-form";
import \{ zodResolver \} from "@hookform/resolvers/zod";
import \{ z \} from "zod";
const schema = z.object(\{
name: z.string().min(2),
email: z.string().email(),
\});
type FormData = z.infer<typeof schema>;
export function ContactForm() \{
const \{
register,
handleSubmit,
formState: \{ errors, isSubmitting \},
reset,
\} = useForm<FormData>(\{
resolver: zodResolver(schema),
defaultValues: \{ name: "", email: "" \},
\});
const onSubmit = async (data: FormData) => \{
await submitToApi(data);
reset();
\};
return (
<form onSubmit=\{handleSubmit(onSubmit)\} noValidate>
<div>
<label htmlFor="name">Name</label>
<input id="name" \{...register("name")\} aria-invalid=\{!!errors.name\} />
\{errors.name && (
<p role="alert" className="text-sm text-red-500">
\{errors.name.message\}
</p>
)\}
</div>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
\{...register("email")\}
aria-invalid=\{!!errors.email\}
/>
\{errors.email && (
<p role="alert" className="text-sm text-red-500">
\{errors.email.message\}
</p>
)\}
</div>
<button type="submit" disabled=\{isSubmitting\}>
\{isSubmitting ? "Sending..." : "Submit"\}
</button>
</form>
);
\}Server Action Form Pattern
// actions.ts
"use server";
import \{ z \} from "zod";
const ContactSchema = z.object(\{
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10).max(1000),
\});
type FormState = \{
errors?: Record<string, string[]>;
message?: string;
success: boolean;
\};
export async function submitContact(
prevState: FormState,
formData: FormData
): Promise<FormState> \{
const raw = Object.fromEntries(formData);
const parsed = ContactSchema.safeParse(raw);
if (!parsed.success) \{
return \{
errors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
success: false,
\};
\}
try \{
await db.contact.create(\{ data: parsed.data \});
return \{ success: true, message: "Message sent!" \};
\} catch \{
return \{ success: false, message: "Failed to send. Try again." \};
\}
\}// ContactForm.tsx
"use client";
import \{ useActionState \} from "react";
import \{ useFormStatus \} from "react-dom";
import \{ submitContact \} from "./actions";
function SubmitButton() \{
const \{ pending \} = useFormStatus();
return (
<button type="submit" disabled=\{pending\}>
\{pending ? "Sending..." : "Send Message"\}
</button>
);
\}
export function ContactForm() \{
const [state, formAction] = useActionState(submitContact, \{
success: false,
\});
return (
<form action=\{formAction\}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" required />
\{state.errors?.name && (
<p className="text-red-500">\{state.errors.name[0]\}</p>
)\}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
\{state.errors?.email && (
<p className="text-red-500">\{state.errors.email[0]\}</p>
)\}
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
\{state.errors?.message && (
<p className="text-red-500">\{state.errors.message[0]\}</p>
)\}
</div>
\{state.message && (
<p className=\{state.success ? "text-green-500" : "text-red-500"\}>
\{state.message\}
</p>
)\}
<SubmitButton />
</form>
);
\}shadcn Form Pattern
"use client";
import \{ useForm \} from "react-hook-form";
import \{ zodResolver \} from "@hookform/resolvers/zod";
import \{ z \} from "zod";
import \{
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
\} from "@/components/ui/form";
import \{ Input \} from "@/components/ui/input";
import \{ Button \} from "@/components/ui/button";
const schema = z.object(\{
username: z.string().min(3).max(20),
email: z.string().email(),
\});
export function ProfileForm() \{
const form = useForm<z.infer<typeof schema>>(\{
resolver: zodResolver(schema),
defaultValues: \{ username: "", email: "" \},
\});
return (
<Form \{...form\}>
<form onSubmit=\{form.handleSubmit(onSubmit)\} className="space-y-4">
<FormField
control=\{form.control\}
name="username"
render=\{(\{ field \}) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="johndoe" \{...field\} />
</FormControl>
<FormDescription>Your public display name.</FormDescription>
<FormMessage />
</FormItem>
)\}
/>
<Button type="submit">Save</Button>
</form>
</Form>
);
\}Multi-Step Form Pattern
"use client";
import \{ useState \} from "react";
import \{ useForm \} from "react-hook-form";
import \{ zodResolver \} from "@hookform/resolvers/zod";
import \{ z \} from "zod";
const Step1Schema = z.object(\{
name: z.string().min(2),
email: z.string().email(),
\});
const Step2Schema = z.object(\{
address: z.string().min(5),
city: z.string().min(2),
zip: z.string().regex(/^\d\{5\}$/),
\});
const Step3Schema = z.object(\{
cardNumber: z.string().regex(/^\d\{16\}$/),
\});
const FullSchema = Step1Schema.merge(Step2Schema).merge(Step3Schema);
type FullFormData = z.infer<typeof FullSchema>;
const schemas = [Step1Schema, Step2Schema, Step3Schema] as const;
export function MultiStepForm() \{
const [step, setStep] = useState(0);
const form = useForm<FullFormData>(\{
resolver: zodResolver(schemas[step]),
mode: "onTouched",
defaultValues: \{
name: "", email: "", address: "", city: "", zip: "", cardNumber: "",
\},
\});
const onNext = async () => \{
const valid = await form.trigger();
if (valid) setStep((s) => s + 1);
\};
const onBack = () => setStep((s) => s - 1);
const onSubmit = async (data: FullFormData) => \{
// Final validation with full schema
const result = FullSchema.safeParse(data);
if (result.success) await submitOrder(result.data);
\};
return (
<form onSubmit=\{form.handleSubmit(onSubmit)\}>
\{step === 0 && <Step1Fields form=\{form\} />\}
\{step === 1 && <Step2Fields form=\{form\} />\}
\{step === 2 && <Step3Fields form=\{form\} />\}
<div className="flex gap-2">
\{step > 0 && <button type="button" onClick=\{onBack\}>Back</button>\}
\{step < 2 ? (
<button type="button" onClick=\{onNext\}>Next</button>
) : (
<button type="submit">Place Order</button>
)\}
</div>
</form>
);
\}Error Handling Rules
- Show field errors inline - Directly below the input, not in a toast
- Use aria-invalid and role="alert" - For accessibility
- Show server errors at the form level - Network errors, auth errors above the submit button
- Validate on blur, re-validate on change - Use
mode: "onTouched"in React Hook Form - Never clear the form on error - Preserve user input
- Provide specific error messages - "Name must be at least 2 characters" not "Invalid input"
Form Architecture Rules
- Single source of truth for validation - Define Zod schema once, infer types from it
- Server-side validation is mandatory - Client validation is a UX enhancement, not security
- Progressive enhancement - Server Action forms work without JavaScript
- Controlled inputs by default - Use React Hook Form's controller for complex inputs
- Debounce async validation - Do not hit the server on every keystroke
## Working Example
### Example 1: User asks "Build a signup form with validation"
**User prompt:** "I need a signup form with name, email, password, and confirm password with Zod validation."
**Skill-guided response would produce:**
- A Zod schema with password confirmation refinement
- A React Hook Form component with zodResolver
- Inline error display with aria attributes
- TypeScript types inferred from the schema
- Both client and server validation
### Example 2: User asks "How do I handle file uploads in a form?"
**Skill-guided response would include:**
- Zod schema with File validation (size, type)
- React Hook Form with `Controller` for the file input
- Server Action that processes FormData with the file
- Progress indication pattern
## Deep Dive
### How the Skill Works
This skill provides:
1. **Decision tree** - Chooses the right form pattern for the scenario
2. **Schema patterns** - Zod recipes for common validation needs
3. **Integration patterns** - React Hook Form + Zod, Server Actions, shadcn/ui
4. **Error handling rules** - Accessibility and UX best practices
5. **Advanced patterns** - Multi-step, file upload, optimistic forms
### Customization
- Add your project's form component library patterns
- Include custom validation rules specific to your domain
- Specify error display conventions
- Add API integration patterns for your backend
### How to Install
```bash
mkdir -p .claude/skills/forms-validation
# Paste the Recipe content into .claude/skills/forms-validation/SKILL.md
Gotchas
- useFormStatus must be in a child component - It reads the status of the nearest parent
<form>, not the form in the same component. - zodResolver validates the entire schema - For multi-step forms, pass the step-specific schema to the resolver.
- Server Actions receive FormData, not typed objects - Always parse with Zod on the server side.
- z.coerce.number() is needed for form inputs because
formData.get()always returns strings. - File inputs cannot be controlled - Use React Hook Form's
Controllerorregisterwith manual handling. - defaultValues must match the schema shape - Missing defaults cause React Hook Form to treat fields as uncontrolled.
Alternatives
| Approach | When to Use |
|---|---|
| Formik | Legacy projects already using Formik |
| Conform | Server-first form validation (pairs well with Remix) |
| Native HTML validation | Simple forms with basic requirements |
| Final Form | Lightweight alternative to React Hook Form |
| Valibot | Smaller bundle alternative to Zod |
FAQs
When should you use a Server Action form instead of React Hook Form?
- When the form does not require complex client-side interactions (conditional fields, multi-step, dynamic arrays)
- When you want progressive enhancement (the form works without JavaScript)
- Use Zod validation inside the Server Action plus
useActionStatefor state management
How do you share a single Zod schema between client and server validation?
Define the schema in a shared file and import it in both places:
// schemas/contact.ts
import { z } from "zod";
export const ContactSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
});
export type Contact = z.infer<typeof ContactSchema>;- Import in your React Hook Form component for the
zodResolver - Import in your Server Action for
safeParse
What is the purpose of z.coerce.number() and when is it needed?
formData.get()always returns strings, even for number inputsz.coerce.number()converts the string to a number before validating- Without it, Zod will reject the value as "not a number"
Why must useFormStatus be placed in a child component of the form?
useFormStatusreads the pending status from the nearest parent<form>element- If called in the same component that renders the
<form>, there is no parent form yet - Always extract a separate
SubmitButtoncomponent that lives inside the form
How do you type the return value of a Server Action for use with useActionState?
type FormState = {
errors?: Record<string, string[]>;
message?: string;
success: boolean;
};
export async function submitContact(
prevState: FormState,
formData: FormData
): Promise<FormState> {
// ...
}- The action's first argument is the previous state, second is
FormData - The return type must match the initial state shape passed to
useActionState
How does discriminatedUnion work for conditional form validation?
z.discriminatedUnion("method", [...])switches validation rules based on a discriminator field- Each branch defines its own required fields (e.g., card requires cardNumber, PayPal requires paypalEmail)
- TypeScript narrows the type automatically when you check the discriminator value
Gotcha: What happens if you forget defaultValues in React Hook Form?
- Fields without default values are treated as uncontrolled inputs
- This causes a React warning about switching from uncontrolled to controlled
- Always provide
defaultValuesthat match the full shape of your Zod schema
How do you validate file uploads with Zod?
const UploadSchema = z.object({
avatar: z
.instanceof(File)
.refine((f) => f.size <= 5 * 1024 * 1024, "Max 5MB")
.refine(
(f) => ["image/jpeg", "image/png"].includes(f.type),
"Only JPEG and PNG accepted"
),
});- Use
z.instanceof(File)as the base type - Chain
.refine()calls for size and MIME type checks
How do you handle per-step validation in a multi-step form?
- Define a separate Zod schema for each step (
Step1Schema,Step2Schema, etc.) - Pass the current step's schema to
zodResolverin React Hook Form - Use
form.trigger()to validate the current step before advancing - On final submit, validate with the merged
FullSchema
What is the correct TypeScript type for the zodResolver generic?
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({ name: z.string().min(2) });
type FormData = z.infer<typeof schema>;
const form = useForm<FormData>({
resolver: zodResolver(schema),
});- Infer the type from the schema with
z.infer<typeof schema> - Pass that inferred type as the generic to
useForm<FormData>
Gotcha: Why does z.coerce behave differently from z.string().transform()?
z.coerce.number()coerces the input before any validation runsz.string().transform(Number)validates as string first, then transforms- For form inputs that arrive as strings,
z.coerceis simpler and more direct
What are the error handling rules for forms described on this page?
- Show field errors inline directly below the input
- Use
aria-invalidandrole="alert"for accessibility - Show server-level errors (network, auth) at the form level above the submit button
- Validate on blur, re-validate on change (
mode: "onTouched") - Never clear the form on error