form-state-patterns

Production form patterns with React Hook Form v7 + Zod - type-safe, performant, accessible.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "form-state-patterns" with this command: npx skills add yonatangross/orchestkit/yonatangross-orchestkit-form-state-patterns

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>}

  &#x3C;input type="password" {...register('password')} />
  {errors.password &#x26;&#x26; &#x3C;span role="alert">{errors.password.message}&#x3C;/span>}

  &#x3C;input type="password" {...register('confirmPassword')} />
  {errors.confirmPassword &#x26;&#x26; &#x3C;span role="alert">{errors.confirmPassword.message}&#x3C;/span>}

  &#x3C;button type="submit" disabled={isSubmitting}>
    {isSubmitting ? 'Submitting...' : 'Sign Up'}
  &#x3C;/button>
&#x3C;/form>

); }

  1. 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> ); }

  1. 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 });

  1. 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>}

  &#x3C;input name="email" />
  {state?.errors?.email &#x26;&#x26; &#x3C;span>{state.errors.email[0]}&#x3C;/span>}

  &#x3C;textarea name="message" />
  {state?.errors?.message &#x26;&#x26; &#x3C;span>{state.errors.message[0]}&#x3C;/span>}

  &#x3C;button type="submit" disabled={isPending}>
    {isPending ? 'Sending...' : 'Send'}
  &#x3C;/button>
&#x3C;/form>

); }

  1. 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 />}

    &#x3C;div>
      {step > 0 &#x26;&#x26; &#x3C;button type="button" onClick={() => setStep(s => s - 1)}>Back&#x3C;/button>}
      {step &#x3C; steps.length - 1 &#x26;&#x26; &#x3C;button type="button" onClick={nextStep}>Next&#x3C;/button>}
      {step === steps.length - 1 &#x26;&#x26; &#x3C;button type="submit">Submit&#x3C;/button>}
    &#x3C;/div>
  &#x3C;/form>
&#x3C;/FormProvider>

); }

  1. 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')} /> </> ); }

  1. 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 Query

  • zustand-patterns

  • Form wizard state with multi-step persistence

  • input-validation

  • Server-side validation and sanitization

  • accessibility-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 patterns

  • scripts/form-template.tsx

  • Production form template

  • checklists/form-checklist.md

  • Implementation checklist

  • examples/form-examples.md

  • Real-world form examples

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

responsive-patterns

No summary provided by upstream source.

Repository SourceNeeds Review
General

domain-driven-design

No summary provided by upstream source.

Repository SourceNeeds Review
General

dashboard-patterns

No summary provided by upstream source.

Repository SourceNeeds Review