Agent skill
form-state-patterns
React Hook Form v7 with Zod validation, React 19 useActionState, Server Actions, field arrays, and async validation. Use when building complex forms, validation flows, or server action forms.
Install this agent skill to your Project
npx add-skill https://github.com/majiayu000/claude-skill-registry/tree/main/skills/other/form-state-patterns
SKILL.md
Form State Patterns
Production form patterns with React Hook Form v7 + Zod - type-safe, performant, accessible.
Overview
- Complex forms with validation
- Multi-step wizards
- Dynamic field arrays
- Server-side validation
- Async field validation
- Forms with file uploads
Core Patterns
1. Basic Form with Zod Schema
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const userSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Min 8 characters'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
type UserForm = z.infer<typeof userSchema>;
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<UserForm>({
resolver: zodResolver(userSchema),
defaultValues: { email: '', password: '', confirmPassword: '' },
});
const onSubmit = async (data: UserForm) => {
await api.signup(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} aria-invalid={!!errors.email} />
{errors.email && <span role="alert">{errors.email.message}</span>}
<input type="password" {...register('password')} />
{errors.password && <span role="alert">{errors.password.message}</span>}
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <span role="alert">{errors.confirmPassword.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Sign Up'}
</button>
</form>
);
}
2. Field Arrays (Dynamic Fields)
import { useFieldArray, useForm } from 'react-hook-form';
const orderSchema = z.object({
items: z.array(z.object({
productId: z.string().min(1),
quantity: z.number().min(1).max(100),
})).min(1, 'At least one item required'),
});
function OrderForm() {
const { control, register, handleSubmit } = useForm({
resolver: zodResolver(orderSchema),
defaultValues: { items: [{ productId: '', quantity: 1 }] },
});
const { fields, append, remove } = useFieldArray({
control,
name: 'items',
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.productId`)} />
<input
type="number"
{...register(`items.${index}.quantity`, { valueAsNumber: true })}
/>
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ productId: '', quantity: 1 })}>
Add Item
</button>
<button type="submit">Submit Order</button>
</form>
);
}
3. Async Field Validation
const usernameSchema = z.object({
username: z.string()
.min(3)
.refine(async (value) => {
const available = await checkUsernameAvailability(value);
return available;
}, 'Username already taken'),
});
// Or with mode: 'onBlur' for better UX
const { register } = useForm({
resolver: zodResolver(usernameSchema),
mode: 'onBlur', // Validate on blur, not on every keystroke
});
4. Server Actions (React 19 / Next.js)
// 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),
});
export async function submitContact(formData: FormData) {
const result = contactSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
await saveContact(result.data);
return { success: true };
}
// Component
'use client';
import { useActionState } from 'react';
import { submitContact } from './actions';
function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, null);
return (
<form action={formAction}>
<input name="name" />
{state?.errors?.name && <span>{state.errors.name[0]}</span>}
<input name="email" />
{state?.errors?.email && <span>{state.errors.email[0]}</span>}
<textarea name="message" />
{state?.errors?.message && <span>{state.errors.message[0]}</span>}
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send'}
</button>
</form>
);
}
5. Multi-Step Wizard
const steps = ['personal', 'address', 'payment'] as const;
const wizardSchema = z.object({
personal: z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
}),
address: z.object({
street: z.string().min(1),
city: z.string().min(1),
}),
payment: z.object({
cardNumber: z.string().length(16),
}),
});
function WizardForm() {
const [step, setStep] = useState(0);
const methods = useForm({
resolver: zodResolver(wizardSchema),
mode: 'onTouched',
});
const nextStep = async () => {
const stepKey = steps[step];
const isValid = await methods.trigger(stepKey);
if (isValid) setStep((s) => Math.min(s + 1, steps.length - 1));
};
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
{step === 0 && <PersonalStep />}
{step === 1 && <AddressStep />}
{step === 2 && <PaymentStep />}
<div>
{step > 0 && <button type="button" onClick={() => setStep(s => s - 1)}>Back</button>}
{step < steps.length - 1 && <button type="button" onClick={nextStep}>Next</button>}
{step === steps.length - 1 && <button type="submit">Submit</button>}
</div>
</form>
</FormProvider>
);
}
6. File Upload with Preview
const fileSchema = z.object({
avatar: z
.instanceof(FileList)
.refine((files) => files.length === 1, 'File required')
.refine((files) => files[0]?.size <= 5_000_000, 'Max 5MB')
.refine(
(files) => ['image/jpeg', 'image/png'].includes(files[0]?.type),
'Only JPEG/PNG'
),
});
function AvatarUpload() {
const [preview, setPreview] = useState<string | null>(null);
const { register, watch } = useForm({ resolver: zodResolver(fileSchema) });
const avatar = watch('avatar');
useEffect(() => {
if (avatar?.[0]) {
setPreview(URL.createObjectURL(avatar[0]));
}
}, [avatar]);
return (
<>
{preview && <img src={preview} alt="Preview" />}
<input type="file" accept="image/*" {...register('avatar')} />
</>
);
}
7. Controlled Components Integration
import { Controller } from 'react-hook-form';
import { DatePicker } from '@/components/ui/date-picker';
function EventForm() {
const { control } = useForm();
return (
<Controller
name="eventDate"
control={control}
render={({ field, fieldState }) => (
<DatePicker
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
error={fieldState.error?.message}
/>
)}
/>
);
}
Performance Optimizations
// Isolate re-renders with Controller
<Controller name="email" control={control} render={...} />
// Use mode: 'onBlur' instead of 'onChange'
useForm({ mode: 'onBlur' });
// Avoid watching entire form
const email = watch('email'); // Good: specific field
const form = watch(); // Bad: entire form triggers re-render
Accessibility Checklist
- All inputs have associated labels
- Error messages use
role="alert" - Invalid inputs have
aria-invalid="true" - Submit button shows loading state
- Focus management on error
Quick Reference
// ✅ Basic form setup with Zod resolver
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { name: '', email: '' },
mode: 'onBlur', // Validate on blur, not every keystroke
});
// ✅ Register inputs with accessibility
<input
{...register('email')}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <p id="email-error" role="alert">{errors.email.message}</p>}
// ✅ Controller for third-party components
<Controller
name="date"
control={control}
render={({ field, fieldState }) => (
<DatePicker value={field.value} onChange={field.onChange} error={fieldState.error} />
)}
/>
// ✅ useActionState for React 19 Server Actions
const [state, formAction, isPending] = useActionState(serverAction, initialState);
// ❌ NEVER watch entire form (causes full re-render)
const allValues = watch(); // BAD
// ❌ NEVER use index as key in field arrays
fields.map((field, index) => <div key={index}>...</div>) // BAD - use field.id
Key Decisions
| Decision | Option A | Option B | Recommendation |
|---|---|---|---|
| Validation library | Yup | Zod | Zod - better TypeScript inference, smaller bundle |
| Validation mode | onChange | onBlur | onBlur - better performance, less noise |
| Complex components | register | Controller | Controller - for non-native inputs |
| Server validation | Client-only | Server Actions | Server Actions - for mutations with React 19 |
| Form state lib | Formik | React Hook Form | RHF - better performance, less re-renders |
| Field arrays | Manual state | useFieldArray | useFieldArray - built-in add/remove/swap |
Anti-Patterns (FORBIDDEN)
// ❌ FORBIDDEN: Watching entire form
const form = watch(); // Re-renders on EVERY change to ANY field
// ❌ FORBIDDEN: Using index as key in field arrays
{fields.map((field, index) => (
<div key={index}> // WRONG - will cause bugs on reorder/remove
<input {...register(`items.${index}.name`)} />
</div>
))}
// ✅ CORRECT: Use field.id
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
</div>
))}
// ❌ FORBIDDEN: Missing defaultValues for all fields
useForm({
resolver: zodResolver(schema),
// Missing defaultValues causes uncontrolled->controlled warning
});
// ❌ FORBIDDEN: Using native validation with Zod
<input type="email" required {...register('email')} /> // Conflicts with Zod
// ✅ CORRECT: Disable native validation
<form onSubmit={handleSubmit(onSubmit)} noValidate>
// ❌ FORBIDDEN: setError without manual clearErrors
const onSubmit = async (data) => {
const result = await api.submit(data);
if (!result.success) {
setError('email', { message: 'Email taken' });
// Missing clearErrors on next submit!
}
};
// ❌ FORBIDDEN: Async validation on every keystroke
const schema = z.object({
username: z.string().refine(async (val) => {
return await checkAvailable(val); // Fires on every character!
}),
});
// ✅ CORRECT: Use mode: 'onBlur' or debounce
useForm({ mode: 'onBlur' });
// ❌ FORBIDDEN: Missing error messages in Zod
const schema = z.object({
email: z.string().email(), // Generic "Invalid" error
});
// ✅ CORRECT: Custom error messages
const schema = z.object({
email: z.string().email('Please enter a valid email address'),
});
Related Skills
tanstack-query-advanced- Combine form mutations with TanStack Queryzustand-patterns- Form wizard state with multi-step persistenceinput-validation- Server-side validation and sanitizationaccessibility-specialist- WCAG compliance for forms
Capability Details
zod-validation
Keywords: zod, schema, validation, refine, transform, parse Solves: Type-safe validation with automatic TypeScript inference
field-arrays
Keywords: useFieldArray, dynamic, add, remove, append, swap, move Solves: Dynamic forms with add/remove items like invoices, surveys
server-actions
Keywords: useActionState, Server Actions, 'use server', formData Solves: React 19 progressive enhancement with server-side validation
multi-step-wizard
Keywords: wizard, steps, trigger, FormProvider, partial validation Solves: Complex multi-page forms with step-by-step validation
async-validation
Keywords: async, refine, debounce, username, availability Solves: Server-side validation during input (e.g., username availability)
file-upload
Keywords: FileList, File, upload, preview, drag-drop, validation Solves: File input validation with size, type, and preview handling
References
references/validation-patterns.md- Advanced Zod patternstemplates/form-template.tsx- Production form templatechecklists/form-checklist.md- Implementation checklistexamples/form-examples.md- Real-world form examples
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
agent-ops-spec
Manage specification documents in .agent/specs/. Use when user provides requirements, acceptance criteria, or feature descriptions that need to be tracked and validated against implementation.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-testing
Test strategy, execution, and coverage analysis. Use when designing tests, running test suites, or analyzing test results beyond baseline checks.
agent-ops-state
Maintain .agent state files. Use at session start, after meaningful steps, and before concluding: read/update constitution/memory/focus/issues/baseline consistently.
Didn't find tool you were looking for?