Files
gh-hopeoverture-worldbuildi…/skills/form-generator-rhf-zod/references/rhf-patterns.md
2025-11-29 18:46:22 +08:00

13 KiB

React Hook Form Patterns Reference

Core Hooks

useForm

Main hook for form management:

const form = useForm<FormValues>({
  resolver: zodResolver(schema),
  defaultValues: { name: '', email: '' },
  mode: 'onBlur', // 'onChange' | 'onBlur' | 'onSubmit' | 'onTouched' | 'all'
  reValidateMode: 'onChange', // When to re-validate after first error
  criteriaMode: 'firstError', // 'firstError' | 'all'
  shouldFocusError: true,
  shouldUnregister: false,
  shouldUseNativeValidation: false,
  delayError: 500 // Delay error display (ms)
})

useFieldArray

Manage dynamic field arrays:

const { fields, append, prepend, remove, swap, move, insert } = useFieldArray({
  control: form.control,
  name: 'items',
  keyName: 'id' // Custom key name (default: 'id')
})

useWatch

Watch field values without re-rendering entire form:

const watchedValue = useWatch({
  control: form.control,
  name: 'fieldName',
  defaultValue: 'default'
})

// Watch multiple fields
const [field1, field2] = useWatch({
  control: form.control,
  name: ['field1', 'field2']
})

// Watch all fields
const allValues = useWatch({ control: form.control })

useFormState

Access form state without subscribing to all changes:

const { isDirty, isValid, errors, isSubmitting } = useFormState({
  control: form.control
})

useController

Lower-level field control:

const { field, fieldState, formState } = useController({
  name: 'fieldName',
  control: form.control,
  rules: { required: true },
  defaultValue: ''
})

Form Validation Modes

onSubmit (Default)

  • Validates on form submission
  • Best for simple forms
  • Minimal re-renders
  • Delayed feedback
const form = useForm({ mode: 'onSubmit' })

onBlur

  • Validates when field loses focus
  • Good balance of UX and performance
  • Recommended for most forms
const form = useForm({ mode: 'onBlur' })

onChange

  • Validates on every keystroke
  • Real-time feedback
  • More re-renders
  • Good for complex validation
const form = useForm({ mode: 'onChange' })

onTouched

  • Validates after field is touched then on every change
  • Progressive enhancement approach
const form = useForm({ mode: 'onTouched' })

all

  • Validates on all events
  • Most feedback but most re-renders
const form = useForm({ mode: 'all' })

Form Methods

Form State Access

// Check if form is dirty
form.formState.isDirty

// Check if form is valid
form.formState.isValid

// Check if submitting
form.formState.isSubmitting

// Get all errors
form.formState.errors

// Check if field is touched
form.formState.touchedFields.fieldName

// Get dirty fields
form.formState.dirtyFields

// Submit count
form.formState.submitCount

Form Actions

// Submit form
form.handleSubmit(onSubmit, onError)

// Reset form
form.reset() // to default values
form.reset({ name: 'New Name' }) // to specific values

// Set value
form.setValue('fieldName', 'value', {
  shouldValidate: true,
  shouldDirty: true,
  shouldTouch: true
})

// Get value
const value = form.getValues('fieldName')
const allValues = form.getValues()

// Clear errors
form.clearErrors() // all errors
form.clearErrors('fieldName') // specific field

// Set error
form.setError('fieldName', {
  type: 'manual',
  message: 'Error message'
})

// Trigger validation
form.trigger() // all fields
form.trigger('fieldName') // specific field
form.trigger(['field1', 'field2']) // multiple fields

// Set focus
form.setFocus('fieldName')

Advanced Patterns

Dependent Fields

const faction = form.watch('faction')

<FormField
  control={form.control}
  name="rank"
  render={({ field }) => (
    <FormItem>
      <FormLabel>Rank</FormLabel>
      <Select onValueChange={field.onChange} defaultValue={field.value}>
        <FormControl>
          <SelectTrigger>
            <SelectValue placeholder="Select rank" />
          </SelectTrigger>
        </FormControl>
        <SelectContent>
          {getRanksForFaction(faction).map(rank => (
            <SelectItem key={rank.id} value={rank.id}>
              {rank.name}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
      <FormMessage />
    </FormItem>
  )}
/>

Async Validation

const schema = z.object({
  username: z.string()
    .min(3)
    .refine(async (username) => {
      const available = await checkUsernameAvailability(username)
      return available
    }, {
      message: 'Username already taken'
    })
})

// Use mode: 'onBlur' for better UX with async validation
const form = useForm({
  resolver: zodResolver(schema),
  mode: 'onBlur'
})

Form Context

Share form between multiple components:

// Parent component
import { FormProvider } from 'react-hook-form'

const form = useForm()

<FormProvider {...form}>
  <ChildComponent1 />
  <ChildComponent2 />
</FormProvider>

// Child component
import { useFormContext } from 'react-hook-form'

function ChildComponent() {
  const form = useFormContext()
  // Use form methods
}

Controlled Components

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

<Controller
  name="customField"
  control={form.control}
  render={({ field, fieldState, formState }) => (
    <CustomComponent
      value={field.value}
      onChange={field.onChange}
      onBlur={field.onBlur}
      error={fieldState.error?.message}
    />
  )}
/>

Transform Values on Submit

const onSubmit = form.handleSubmit((data) => {
  const transformed = {
    ...data,
    age: parseInt(data.age),
    tags: data.tags.map(t => t.toLowerCase()),
    createdAt: new Date()
  }
  // Submit transformed data
})

Dirty Field Tracking

const dirtyFields = form.formState.dirtyFields

function onSubmit(data: FormValues) {
  // Only submit changed fields
  const changedData = Object.keys(dirtyFields).reduce((acc, key) => {
    acc[key] = data[key]
    return acc
  }, {} as Partial<FormValues>)

  await updateEntity(id, changedData)
}

Multi-Step Form

const [step, setStep] = useState(1)

function onSubmit(data: FormValues) {
  if (step < 3) {
    setStep(step + 1)
  } else {
    // Final submission
    submitData(data)
  }
}

<Form {...form}>
  <form onSubmit={form.handleSubmit(onSubmit)}>
    {step === 1 && <Step1Fields control={form.control} />}
    {step === 2 && <Step2Fields control={form.control} />}
    {step === 3 && <Step3Fields control={form.control} />}

    <div className="flex gap-2">
      {step > 1 && (
        <Button type="button" onClick={() => setStep(step - 1)}>
          Previous
        </Button>
      )}
      <Button type="submit">
        {step < 3 ? 'Next' : 'Submit'}
      </Button>
    </div>
  </form>
</Form>

Reset with Default Values

// Reset to specific values after successful submission
async function onSubmit(data: FormValues) {
  const result = await createEntity(data)

  if (result.success) {
    form.reset({
      name: '',
      type: 'default',
      // Keep some values
      category: data.category
    })
  }
}

Error Handling

async function onSubmit(data: FormValues) {
  try {
    const result = await submitAction(data)

    if (!result.success) {
      // Set server errors
      if (result.errors) {
        Object.entries(result.errors).forEach(([field, message]) => {
          form.setError(field as any, {
            type: 'server',
            message: message as string
          })
        })
      }

      // Set root error for general issues
      if (result.message) {
        form.setError('root', {
          type: 'server',
          message: result.message
        })
      }
    } else {
      toast.success('Saved successfully')
      form.reset()
    }
  } catch (error) {
    form.setError('root', {
      type: 'server',
      message: 'An unexpected error occurred'
    })
  }
}

// Display root error
{form.formState.errors.root && (
  <Alert variant="destructive">
    <AlertDescription>
      {form.formState.errors.root.message}
    </AlertDescription>
  </Alert>
)}

Optimistic Updates

const [optimisticData, setOptimisticData] = useState<Entity | null>(null)

async function onSubmit(data: FormValues) {
  // Show optimistic state
  setOptimisticData(data as Entity)
  toast.success('Saving...')

  try {
    const result = await saveEntity(data)

    if (result.success) {
      // Confirm success
      setOptimisticData(result.data)
      toast.success('Saved successfully')
    } else {
      // Revert optimistic state
      setOptimisticData(null)
      toast.error('Save failed')
    }
  } catch (error) {
    setOptimisticData(null)
    toast.error('An error occurred')
  }
}

Auto-Save Draft

import { useEffect } from 'react'
import { useDebouncedCallback } from 'use-debounce'

const saveDraft = useDebouncedCallback(async (data: FormValues) => {
  await saveDraftToStorage(data)
}, 1000)

useEffect(() => {
  const subscription = form.watch((data) => {
    if (form.formState.isDirty) {
      saveDraft(data as FormValues)
    }
  })
  return () => subscription.unsubscribe()
}, [form.watch, form.formState.isDirty])

// Load draft on mount
useEffect(() => {
  const draft = loadDraftFromStorage()
  if (draft) {
    form.reset(draft)
  }
}, [])

Conditional Required Fields

const schema = z.object({
  type: z.enum(['character', 'location']),
  characterRace: z.string().optional(),
  locationType: z.string().optional()
}).refine(
  (data) => {
    if (data.type === 'character') {
      return data.characterRace !== undefined && data.characterRace.length > 0
    }
    return true
  },
  {
    message: 'Race is required for characters',
    path: ['characterRace']
  }
).refine(
  (data) => {
    if (data.type === 'location') {
      return data.locationType !== undefined && data.locationType.length > 0
    }
    return true
  },
  {
    message: 'Location type is required',
    path: ['locationType']
  }
)

Field Masking/Formatting

<FormField
  control={form.control}
  name="phone"
  render={({ field }) => (
    <FormItem>
      <FormLabel>Phone</FormLabel>
      <FormControl>
        <Input
          {...field}
          onChange={(e) => {
            const formatted = formatPhoneNumber(e.target.value)
            field.onChange(formatted)
          }}
          placeholder="(123) 456-7890"
        />
      </FormControl>
      <FormMessage />
    </FormItem>
  )}
/>

function formatPhoneNumber(value: string): string {
  const numbers = value.replace(/\D/g, '')
  const match = numbers.match(/^(\d{0,3})(\d{0,3})(\d{0,4})$/)
  if (!match) return value

  const formatted = [match[1], match[2], match[3]]
    .filter(Boolean)
    .join('-')

  return formatted
}

Performance Optimization

Isolate Re-renders

// Bad: Entire form re-renders on field change
const value = form.watch('field')

// Good: Only component re-renders
function WatchedField() {
  const value = useWatch({ control: form.control, name: 'field' })
  return <div>{value}</div>
}

Use FormField for Each Field

// FormField isolates re-renders to individual fields
<FormField
  control={form.control}
  name="field"
  render={({ field }) => (
    <FormItem>
      <FormControl>
        <Input {...field} />
      </FormControl>
    </FormItem>
  )}
/>

Memoize Expensive Computations

import { useMemo } from 'react'

const options = useMemo(() => {
  return computeExpensiveOptions()
}, [dependencies])

Testing Patterns

import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MyForm } from './MyForm'

describe('MyForm', () => {
  it('submits form with valid data', async () => {
    const onSubmit = vi.fn()
    render(<MyForm onSubmit={onSubmit} />)

    await userEvent.type(screen.getByLabelText(/name/i), 'Test Name')
    await userEvent.click(screen.getByRole('button', { name: /submit/i }))

    await waitFor(() => {
      expect(onSubmit).toHaveBeenCalledWith({
        name: 'Test Name'
      })
    })
  })

  it('shows validation errors', async () => {
    render(<MyForm />)

    await userEvent.click(screen.getByRole('button', { name: /submit/i }))

    expect(await screen.findByText(/name is required/i)).toBeInTheDocument()
  })

  it('clears errors on valid input', async () => {
    render(<MyForm />)

    const input = screen.getByLabelText(/name/i)

    await userEvent.click(screen.getByRole('button', { name: /submit/i }))
    expect(await screen.findByText(/name is required/i)).toBeInTheDocument()

    await userEvent.type(input, 'Valid Name')
    await waitFor(() => {
      expect(screen.queryByText(/name is required/i)).not.toBeInTheDocument()
    })
  })
})