form-handling
Skill1 starsChanged 8 months agoMITView on GitHub
Form patterns with React Hook Form + Zod validation. Covers typed forms, field arrays, multi-step wizards, server actions, and accessible error handling.
- Installs packages
- Sends data out
2 other projects use a version of this, 1 of them edited.
Adopted by
Most recently taken up by
- PMDevSolutions/Aureliusedited, first seen Oct 24, 2026
- HamzaSheikh768/Agents-Skills-Laptheir own version, first seen Feb 4, 2026
Other versions2 projects use a version of this.
Word for word · 0
None found.
Edited · 1
- PMDevSolutions/Aurelius★ 999% same
Wrote their own version · 1
- HamzaSheikh768/Agents-Skills-Lap★ 3same name
How matches are found. Word for word: the exact same text. Edited: shares passages with this file (“99% same” means 99% of its five-word phrases also appear here; 10% or more counts). Their own version: similar meaning, or, weakest, the same name. We can’t tell which project had it first. Projects are counted among the 200 most-starred candidates, and mentions are links to the repository we found, up to 50 per site, so both counts are a floor.
In this repository9 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- animation-motionSkill
- auth-flowsSkill
- react-accessibilitySkill
- react-component-developmentSkill
- react-performance-optimizationSkill
- react-testing-workflowsSkill
- seo-metadataSkill
- state-managementSkill
The file
We split this skill into the rules it gives an agent
3 rules. About 0 are common, 1 is distinctive, and 2 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
2
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 241
Use useFieldArray for repeating groups like invoice line items, team members, or address entries.
- Very specific: line 633
Use useActionState to bridge React Hook Form with Next.js server actions for progressive enhancement.
- Distinctive: line 751
Every form built with this skill MUST satisfy these requirements:
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Form Handling — React Hook Form + Zod
When to Use This Skill
Activate this skill when:
- Building any form (login, registration, settings, checkout)
- Adding client-side or server-side validation
- Building multi-step wizards or survey flows
- Handling file uploads with validation
- Integrating forms with Next.js server actions
- Working with dynamic/repeating field groups (invoices, line items)
Stack
pnpm add react-hook-form zod @hookform/resolvers| Package | Purpose |
react-hook-form | Performant form state management with uncontrolled inputs |
zod | TypeScript-first schema validation with type inference |
@hookform/resolvers | Bridges Zod schemas to React Hook Form validation |
1. Basic Typed Form Pattern
Define the schema once with Zod, infer the TypeScript type, and pass it to useForm. This eliminates type duplication between validation and form state.
// schemas/contact.ts
import { z } from "zod";
export const contactSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters")
.max(100, "Name must be under 100 characters"),
email: z
.string()
.email("Please enter a valid email address"),
message: z
.string()
.min(10, "Message must be at least 10 characters")
.max(1000, "Message must be under 1000 characters"),
});
export type ContactFormData = z.infer<typeof contactSchema>;// components/ContactForm.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { contactSchema, type ContactFormData } from "@/schemas/contact";
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
defaultValues: {
name: "",
email: "",
message: "",
},
});
async function onSubmit(data: ContactFormData) {
// data is fully typed and validated at this point
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (response.ok) {
reset();
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<FormField
label="Name"
error={errors.name?.message}
>
<input
{...register("name")}
type="text"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? "name-error" : undefined}
/>
</FormField>
<FormField
label="Email"
error={errors.email?.message}
>
<input
{...register("email")}
type="email"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>
</FormField>
<FormField
label="Message"
error={errors.message?.message}
>
<textarea
{...register("message")}
rows={4}
aria-invalid={!!errors.message}
aria-describedby={errors.message ? "message-error" : undefined}
/>
</FormField>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending..." : "Send Message"}
</button>
</form>
);
}2. Reusable FormField Component
A shared wrapper that handles labels, error display, and ARIA attributes consistently across all forms.
// components/ui/FormField.tsx
import { type ReactNode } from "react";
interface FormFieldProps {
label: string;
name?: string;
error?: string;
description?: string;
required?: boolean;
children: ReactNode;
className?: string;
}
export function FormField({
label,
name,
error,
description,
required = false,
children,
className,
}: FormFieldProps) {
const fieldId = name ?? label.toLowerCase().replace(/\s+/g, "-");
const errorId = `${fieldId}-error`;
const descriptionId = `${fieldId}-description`;
return (
<div className={className}>
<label htmlFor={fieldId} className="block text-sm font-medium">
{label}
{required && (
<span className="text-red-500 ml-1" aria-hidden="true">
*
</span>
)}
</label>
{description && (
<p id={descriptionId} className="mt-1 text-sm text-gray-500">
{description}
</p>
)}
<div className="mt-1">{children}</div>
{error && (
<p
id={errorId}
role="alert"
className="mt-1 text-sm text-red-600"
aria-describedby={errorId}
>
{error}
</p>
)}
</div>
);
}Usage with register:
<FormField label="Email" name="email" error={errors.email?.message} required>
<input
id="email"
{...register("email")}
type="email"
className={cn(
"w-full rounded-md border px-3 py-2",
errors.email
? "border-red-500 focus:ring-red-500"
: "border-gray-300 focus:ring-blue-500"
)}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? "email-error" : undefined}
/>
</FormField>3. Dynamic Field Arrays
Use useFieldArray for repeating groups like invoice line items, team members, or address entries.
// schemas/invoice.ts
import { z } from "zod";
export const lineItemSchema = z.object({
description: z.string().min(1, "Description is required"),
quantity: z.coerce.number().min(1, "Minimum quantity is 1"),
unitPrice: z.coerce.number().min(0.01, "Price must be positive"),
});
export const invoiceSchema = z.object({
clientName: z.string().min(1, "Client name is required"),
items: z.array(lineItemSchema).min(1, "Add at least one item"),
});
export type InvoiceFormData = z.infer<typeof invoiceSchema>;// components/InvoiceForm.tsx
"use client";
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { invoiceSchema, type InvoiceFormData } from "@/schemas/invoice";
export function InvoiceForm() {
const {
register,
control,
handleSubmit,
watch,
formState: { errors },
} = useForm<InvoiceFormData>({
resolver: zodResolver(invoiceSchema),
defaultValues: {
clientName: "",
items: [{ description: "", quantity: 1, unitPrice: 0 }],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "items",
});
const watchItems = watch("items");
const total = watchItems.reduce(
(sum, item) => sum + (item.quantity || 0) * (item.unitPrice || 0),
0
);
function onSubmit(data: InvoiceFormData) {
console.log("Invoice:", data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<FormField
label="Client Name"
name="clientName"
error={errors.clientName?.message}
>
<input {...register("clientName")} type="text" />
</FormField>
<fieldset>
<legend className="text-lg font-semibold">Line Items</legend>
{fields.map((field, index) => (
<div key={field.id} className="flex gap-4 items-start">
<FormField
label="Description"
error={errors.items?.[index]?.description?.message}
>
<input
{...register(`items.${index}.description`)}
type="text"
/>
</FormField>
<FormField
label="Qty"
error={errors.items?.[index]?.quantity?.message}
>
<input
{...register(`items.${index}.quantity`)}
type="number"
min={1}
/>
</FormField>
<FormField
label="Unit Price"
error={errors.items?.[index]?.unitPrice?.message}
>
<input
{...register(`items.${index}.unitPrice`)}
type="number"
step="0.01"
min={0}
/>
</FormField>
<button
type="button"
onClick={() => remove(index)}
disabled={fields.length === 1}
aria-label={`Remove item ${index + 1}`}
>
Remove
</button>
</div>
))}
{errors.items?.root && (
<p role="alert" className="text-sm text-red-600">
{errors.items.root.message}
</p>
)}
</fieldset>
<button
type="button"
onClick={() => append({ description: "", quantity: 1, unitPrice: 0 })}
>
+ Add Item
</button>
<div className="text-right font-bold">
Total: ${total.toFixed(2)}
</div>
<button type="submit">Create Invoice</button>
</form>
);
}4. Multi-Step Wizard
A custom useMultiStepForm hook with per-step Zod validation. Each step validates only its own fields before advancing.
// hooks/useMultiStepForm.ts
import { useState, useCallback } from "react";
import { type UseFormTrigger, type FieldPath } from "react-hook-form";
interface Step<T extends Record<string, unknown>> {
id: string;
title: string;
fields: FieldPath<T>[];
}
export function useMultiStepForm<T extends Record<string, unknown>>(
steps: Step<T>[],
trigger: UseFormTrigger<T>
) {
const [currentStep, setCurrentStep] = useState(0);
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === steps.length - 1;
const step = steps[currentStep];
const progress = ((currentStep + 1) / steps.length) * 100;
const next = useCallback(async () => {
// Validate only the fields in the current step
const fieldsValid = await trigger(step.fields);
if (fieldsValid && !isLastStep) {
setCurrentStep((prev) => prev + 1);
}
return fieldsValid;
}, [trigger, step.fields, isLastStep]);
const back = useCallback(() => {
if (!isFirstStep) {
setCurrentStep((prev) => prev - 1);
}
}, [isFirstStep]);
const goTo = useCallback((index: number) => {
setCurrentStep(index);
}, []);
return {
currentStep,
step,
steps,
isFirstStep,
isLastStep,
progress,
next,
back,
goTo,
};
}// schemas/onboarding.ts
import { z } from "zod";
export const onboardingSchema = z.object({
// Step 1: Account
email: z.string().email("Valid email required"),
password: z.string().min(8, "Password must be at least 8 characters"),
// Step 2: Profile
displayName: z.string().min(2, "Display name required"),
bio: z.string().max(500).optional(),
// Step 3: Preferences
theme: z.enum(["light", "dark", "system"]),
notifications: z.boolean(),
});
export type OnboardingData = z.infer<typeof onboardingSchema>;
export const onboardingSteps = [
{ id: "account", title: "Account", fields: ["email", "password"] as const },
{ id: "profile", title: "Profile", fields: ["displayName", "bio"] as const },
{
id: "preferences",
title: "Preferences",
fields: ["theme", "notifications"] as const,
},
];// components/OnboardingWizard.tsx
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
onboardingSchema,
onboardingSteps,
type OnboardingData,
} from "@/schemas/onboarding";
import { useMultiStepForm } from "@/hooks/useMultiStepForm";
export function OnboardingWizard() {
const {
register,
handleSubmit,
trigger,
formState: { errors, isSubmitting },
} = useForm<OnboardingData>({
resolver: zodResolver(onboardingSchema),
defaultValues: {
email: "",
password: "",
displayName: "",
bio: "",
theme: "system",
notifications: true,
},
});
const {
currentStep,
step,
steps,
isFirstStep,
isLastStep,
progress,
next,
back,
} = useMultiStepForm(onboardingSteps, trigger);
async function onSubmit(data: OnboardingData) {
await fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Progress bar */}
<div
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Step ${currentStep + 1} of ${steps.length}`}
className="h-2 bg-gray-200 rounded-full"
>
<div
className="h-full bg-blue-600 rounded-full transition-all"
style={{ width: `${progress}%` }}
/>
</div>
{/* Step indicators */}
<nav aria-label="Form steps">
<ol className="flex gap-4">
{steps.map((s, i) => (
<li
key={s.id}
aria-current={i === currentStep ? "step" : undefined}
className={i === currentStep ? "font-bold" : "text-gray-500"}
>
{s.title}
</li>
))}
</ol>
</nav>
{/* Step content */}
<div>
{currentStep === 0 && (
<div>
<FormField label="Email" error={errors.email?.message} required>
<input {...register("email")} type="email" />
</FormField>
<FormField
label="Password"
error={errors.password?.message}
required
>
<input {...register("password")} type="password" />
</FormField>
</div>
)}
{currentStep === 1 && (
<div>
<FormField
label="Display Name"
error={errors.displayName?.message}
required
>
<input {...register("displayName")} type="text" />
</FormField>
<FormField label="Bio" error={errors.bio?.message}>
<textarea {...register("bio")} rows={3} />
</FormField>
</div>
)}
{currentStep === 2 && (
<div>
<FormField label="Theme" error={errors.theme?.message}>
<select {...register("theme")}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
</FormField>
<FormField label="Email Notifications">
<label className="flex items-center gap-2">
<input {...register("notifications")} type="checkbox" />
<span>Receive email notifications</span>
</label>
</FormField>
</div>
)}
</div>
{/* Navigation */}
<div className="flex justify-between">
<button type="button" onClick={back} disabled={isFirstStep}>
Back
</button>
{isLastStep ? (
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Submitting..." : "Complete"}
</button>
) : (
<button type="button" onClick={next}>
Next
</button>
)}
</div>
</form>
);
}5. Server Action Integration (Next.js)
Use useActionState to bridge React Hook Form with Next.js server actions for progressive enhancement.
// actions/contact.ts
"use server";
import { contactSchema } from "@/schemas/contact";
interface ActionState {
success: boolean;
message: string;
errors?: Record<string, string[]>;
}
export async function submitContact(
_prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const raw = {
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
};
const result = contactSchema.safeParse(raw);
if (!result.success) {
return {
success: false,
message: "Validation failed",
errors: result.error.flatten().fieldErrors,
};
}
// Save to database, send email, etc.
await saveContactSubmission(result.data);
return {
success: true,
message: "Thank you for your message!",
};
}// components/ContactFormWithAction.tsx
"use client";
import { useActionState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { contactSchema, type ContactFormData } from "@/schemas/contact";
import { submitContact } from "@/actions/contact";
export function ContactFormWithAction() {
const [state, formAction, isPending] = useActionState(submitContact, {
success: false,
message: "",
});
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
});
// Client-side validation first, then server action
const onSubmit = handleSubmit(async (_data, event) => {
const formData = new FormData(event?.target as HTMLFormElement);
formAction(formData);
});
return (
<form action={formAction} onSubmit={onSubmit}>
{state.message && (
<div
role="alert"
className={state.success ? "text-green-600" : "text-red-600"}
>
{state.message}
</div>
)}
<FormField
label="Name"
error={errors.name?.message || state.errors?.name?.[0]}
>
<input {...register("name")} name="name" type="text" />
</FormField>
<FormField
label="Email"
error={errors.email?.message || state.errors?.email?.[0]}
>
<input {...register("email")} name="email" type="email" />
</FormField>
<FormField
label="Message"
error={errors.message?.message || state.errors?.message?.[0]}
>
<textarea {...register("message")} name="message" rows={4} />
</FormField>
<button type="submit" disabled={isPending}>
{isPending ? "Sending..." : "Send"}
</button>
</form>
);
}6. Accessibility Checklist
Every form built with this skill MUST satisfy these requirements:
| Requirement | Implementation |
Every input has a visible <label> | <label htmlFor="fieldId"> matching the input id |
| Errors are announced to screen readers | role="alert" on error messages |
| Inputs link to their errors | aria-describedby="fieldId-error" on the input |
| Invalid state is communicated | aria-invalid={true} when field has an error |
| Required fields are indicated | aria-required="true" or required attribute, plus visible indicator |
| Form-level errors are announced | role="alert" on summary error container |
| Focus management on errors | Move focus to first invalid field after failed submission |
| Submit button shows loading state | disabled + text change during isSubmitting |
| Progress in wizards | role="progressbar" with aria-valuenow |
| Fieldsets group related fields | <fieldset> + <legend> for field groups |
Focus Management on Validation Failure
function onInvalid() {
// Focus the first field with an error
const firstError = document.querySelector<HTMLElement>(
'[aria-invalid="true"]'
);
firstError?.focus();
}
<form onSubmit={handleSubmit(onSubmit, onInvalid)}>7. Testing Forms
Test: Validation Error Display
// __tests__/ContactForm.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { ContactForm } from "../ContactForm";
describe("ContactForm", () => {
it("shows validation errors when submitting empty form", async () => {
const user = userEvent.setup();
render(<ContactForm />);
// Submit empty form
await user.click(screen.getByRole("button", { name: /send/i }));
// Expect error messages
expect(
await screen.findByText(/name must be at least 2 characters/i)
).toBeInTheDocument();
expect(
screen.getByText(/please enter a valid email/i)
).toBeInTheDocument();
expect(
screen.getByText(/message must be at least 10 characters/i)
).toBeInTheDocument();
// Verify aria-invalid is set
expect(screen.getByLabelText(/name/i)).toHaveAttribute(
"aria-invalid",
"true"
);
});
it("submits successfully with valid data", async () => {
const user = userEvent.setup();
const mockFetch = vi.spyOn(global, "fetch").mockResolvedValue(
new Response(null, { status: 200 })
);
render(<ContactForm />);
await user.type(screen.getByLabelText(/name/i), "Jane Doe");
await user.type(screen.getByLabelText(/email/i), "jane@example.com");
await user.type(
screen.getByLabelText(/message/i),
"Hello, this is a test message for the form."
);
await user.click(screen.getByRole("button", { name: /send/i }));
// Verify fetch was called with correct data
expect(mockFetch).toHaveBeenCalledWith("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Jane Doe",
email: "jane@example.com",
message: "Hello, this is a test message for the form.",
}),
});
mockFetch.mockRestore();
});
it("disables submit button while submitting", async () => {
const user = userEvent.setup();
vi.spyOn(global, "fetch").mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 1000))
);
render(<ContactForm />);
await user.type(screen.getByLabelText(/name/i), "Jane Doe");
await user.type(screen.getByLabelText(/email/i), "jane@example.com");
await user.type(screen.getByLabelText(/message/i), "A valid message that is long enough.");
await user.click(screen.getByRole("button", { name: /send/i }));
expect(screen.getByRole("button", { name: /sending/i })).toBeDisabled();
});
});Test: Multi-Step Wizard Navigation
// __tests__/OnboardingWizard.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { OnboardingWizard } from "../OnboardingWizard";
describe("OnboardingWizard", () => {
it("validates step 1 before advancing to step 2", async () => {
const user = userEvent.setup();
render(<OnboardingWizard />);
// Try to advance without filling fields
await user.click(screen.getByRole("button", { name: /next/i }));
// Should still be on step 1 with errors
expect(
await screen.findByText(/valid email required/i)
).toBeInTheDocument();
// Fill step 1 correctly
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.type(screen.getByLabelText(/password/i), "securepassword");
await user.click(screen.getByRole("button", { name: /next/i }));
// Should now see step 2
expect(screen.getByLabelText(/display name/i)).toBeInTheDocument();
});
it("allows navigating back without losing data", async () => {
const user = userEvent.setup();
render(<OnboardingWizard />);
// Fill step 1
await user.type(screen.getByLabelText(/email/i), "test@example.com");
await user.type(screen.getByLabelText(/password/i), "securepassword");
await user.click(screen.getByRole("button", { name: /next/i }));
// Go back
await user.click(screen.getByRole("button", { name: /back/i }));
// Data should be preserved
expect(screen.getByLabelText(/email/i)).toHaveValue("test@example.com");
});
});Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

