Skip to content
Development
Skill

/form-validation

React Hook Form + Zod integration, multi-step forms, optimistic validation, server-side error mapping, and file upload patterns.

From plugin
vibecosystem
534200 skills138 agents7 hooks
Install
$ npx -y skills add vibeeval/vibecosystem --skill form-validation --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/form-validation

Context preview

The summary Claude sees to decide when to auto-load this skill.

React Hook Form + Zod integration, multi-step forms, optimistic validation, server-side error mapping, and file upload patterns.

SKILL.md

form-validation.SKILL.md
name: form-validation
description: React Hook Form + Zod integration, multi-step forms, optimistic validation, server-side error mapping, and file upload patterns.

Form Validation

React Hook Form + Zod patterns for robust, accessible forms.

React Hook Form + Zod Setup

// Install: npm install react-hook-form zod @hookform/resolvers

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

// 1. Define schema
const TaskSchema = z.object({
  title:       z.string().min(1, 'Title is required').max(200),
  description: z.string().max(2000).optional(),
  priority:    z.enum(['low', 'medium', 'high']),
  dueDate:     z.string().date('Invalid date').optional(),
})

type TaskFormData = z.infer<typeof TaskSchema>

// 2. Use in component
export function TaskForm({ onSubmit }: { onSubmit: (data: TaskFormData) => Promise<void> }) {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isDirty },
    setError,
    reset,
  } = useForm<TaskFormData>({
    resolver: zodResolver(TaskSchema),
    defaultValues: { priority: 'medium' },
  })

  const submit = handleSubmit(async (data) => {
    try {
      await onSubmit(data)
      reset()
    } catch (err) {
      // Map server errors to fields (see Server-Side Error Mapping)
      setError('title', { message: 'A task with this title already exists' })
    }
  })

  return (
    <form onSubmit={submit} noValidate>
      <div>
        <label htmlFor="title">Title *</label>
        <input
          id="title"
          {...register('title')}
          aria-invalid={!!errors.title}
          aria-describedby={errors.title ? 'title-error' : undefined}
        />
        {errors.title && (
          <p id="title-error" role="alert" className="text-red-600 text-sm">
            {errors.title.message}
          </p>
        )}
      </div>

      <button type="submit" disabled={isSubmitting || !isDirty}>
        {isSubmitting ? 'Saving...' : 'Save Task'}
      </button>
    </form>
  )
}

Form Schema Definition with Zod

import { z } from 'zod'

// Common field patterns
const emailField = z.string().email('Invalid email address').toLowerCase()
const passwordField = z.string()
  .min(8, 'At least 8 characters')
  .regex(/[A-Z]/, 'Must contain uppercase letter')
  .regex(/[0-9]/, 'Must contain a number')

const urlField = z.string().url('Must be a valid URL').optional().or(z.literal(''))

const phoneField = z.string()
  .regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number')
  .optional()

// Cross-field validation (refine)
const PasswordChangeSchema = z
  .object({
    password:        passwordField,
    confirmPassword: z.string(),
  })
  .refine(data => data.password === data.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'],   // error attached to confirmPassword field
  })

// Conditional fields (superRefine)
const EventSchema = z
  .object({
    type:      z.enum(['online', 'in-person']),
    url:       z.string().url().optional(),
    address:   z.string().optional(),
  })
  .superRefine((data, ctx) => {
    if (data.type === 'online' && !data.url) {
      ctx.addIssue({ code: 'custom', message: 'URL required for online events', path: ['url'] })
    }
    if (data.type === 'in-person' && !data.address) {
      ctx.addIssue({ code: 'custom', message: 'Address required', path: ['address'] })
    }
  })

Multi-Step Form State Management

import { useState } from 'react'
import { useForm, FormProvider } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'

const steps = ['Personal', 'Details', 'Review'] as const
type Step = (typeof steps)[number]

// Each step has its own schema
const Step1Schema = z.object({ name: z.string().min(1), email: emailField })
const Step2Schema = z.object({ company: z.string().min(1), role: z.string().min(1) })
const FullSchema = Step1Schema.merge(Step2Schema)

type FormData = z.infer<typeof FullSchema>

export function MultiStepForm() {
  const [currentStep, setCurrentStep] = useState(0)

  const methods = useForm<FormData>({
    resolver: zodResolver(FullSchema),
    mode: 'onTouched',
  })

  const stepSchemas = [Step1Schema, Step2Schema]

  const next = async () => {
    // Validate only current step's fields
    const fieldsToValidate = Object.keys(stepSchemas[currentStep].shape) as (keyof FormData)[]
    const valid = await methods.trigger(fieldsToValidate)
    if (valid) setCurrentStep(s => s + 1)
  }

  const submit = methods.handleSubmit(async (data) => {
    await createUser(data)
  })

  return (
    <FormProvider {...methods}>
      {/* Progress indicator */}
      <nav aria-label="Form steps">
        {steps.map((step, i) => (
          <span key={step} aria-current={i === currentStep ? 'step' : undefined}>
            {step}
          </span>
        ))}
      </nav>

      <form onSubmit={submit}>
        {currentStep === 0 && <Step1Fields />}
        {currentStep === 1 && <Step2Fields />}
        {currentStep === 2 && <ReviewStep />}

        <div className="flex gap-2">
          {currentStep > 0 && (
            <button type="button" onClick={() => setCurrentStep(s => s - 1)}>Back</button>
          )}
          {currentStep < steps.length - 1 ? (
            <button type="button" onClick={next}>Next</button>
          ) : (
            <button type="submit">Submit</button>
          )}
        </div>
      </form>
    </FormProvider>
  )
}

Server-Side Validation Error Mapping

import { useForm } from 'react-hook-form'

// API returns: { errors: { field: string[] } }
interface ApiError {
  errors?: Record<string, string[]>
  message?: string
}

export function RegistrationForm() {
  const { register, handleSubmit, setError, formState: { errors } } = useForm<FormData>()

  const submit = handleSubmit(async (data) => {
    try {
      await registerUser(data)
    } catch (err) {
Read more
Ships withvibecosystem

Your AI software team. Built on Claude Code. vibecosystem turns Claude Code into a full AI software team — 138 specialized agents that plan, build, review, test, and learn from every mistake. No configuration needed — just install and code.

Get the whole plugin

Other skills on vibecosystem.