Documentation
Forms
Form Patterns

Form Patterns

Reusable patterns for building forms in React applications.

Form Component Structure

// components/forms/contact-form.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { contactSchema, type ContactForm } from '@/schemas/contact';
 
interface ContactFormProps {
  onSuccess?: () => void;
  defaultValues?: Partial<ContactForm>;
}
 
export function ContactForm({ onSuccess, defaultValues }: ContactFormProps) {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
    reset,
    setError,
  } = useForm<ContactForm>({
    resolver: zodResolver(contactSchema),
    defaultValues,
  });
 
  const onSubmit = async (data: ContactForm) => {
    try {
      await submitContactForm(data);
      reset();
      onSuccess?.();
    } catch (error) {
      setError('root', {
        message: 'Failed to submit. Please try again.',
      });
    }
  };
 
  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      {errors.root && (
        <div className="error-banner">{errors.root.message}</div>
      )}
 
      {/* Form fields */}
 
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

Reusable Input Components

Text Input

// components/ui/form-input.tsx
import { forwardRef } from 'react';
import { type FieldError } from 'react-hook-form';
 
interface FormInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: FieldError;
}
 
export const FormInput = forwardRef<HTMLInputElement, FormInputProps>(
  ({ label, error, id, ...props }, ref) => {
    const inputId = id || props.name;
 
    return (
      <div className="form-field">
        <label htmlFor={inputId}>{label}</label>
        <input
          ref={ref}
          id={inputId}
          aria-invalid={!!error}
          aria-describedby={error ? `${inputId}-error` : undefined}
          {...props}
        />
        {error && (
          <span id={`${inputId}-error`} className="error">
            {error.message}
          </span>
        )}
      </div>
    );
  }
);
 
FormInput.displayName = 'FormInput';
 
// Usage
<FormInput
  label="Email"
  type="email"
  error={errors.email}
  {...register('email')}
/>

Select Input

// components/ui/form-select.tsx
import { forwardRef } from 'react';
import { type FieldError } from 'react-hook-form';
 
interface Option {
  value: string;
  label: string;
}
 
interface FormSelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
  label: string;
  options: Option[];
  error?: FieldError;
  placeholder?: string;
}
 
export const FormSelect = forwardRef<HTMLSelectElement, FormSelectProps>(
  ({ label, options, error, placeholder, id, ...props }, ref) => {
    const selectId = id || props.name;
 
    return (
      <div className="form-field">
        <label htmlFor={selectId}>{label}</label>
        <select
          ref={ref}
          id={selectId}
          aria-invalid={!!error}
          {...props}
        >
          {placeholder && (
            <option value="">{placeholder}</option>
          )}
          {options.map((option) => (
            <option key={option.value} value={option.value}>
              {option.label}
            </option>
          ))}
        </select>
        {error && <span className="error">{error.message}</span>}
      </div>
    );
  }
);
 
FormSelect.displayName = 'FormSelect';

Checkbox

// components/ui/form-checkbox.tsx
import { forwardRef } from 'react';
import { type FieldError } from 'react-hook-form';
 
interface FormCheckboxProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: FieldError;
}
 
export const FormCheckbox = forwardRef<HTMLInputElement, FormCheckboxProps>(
  ({ label, error, id, ...props }, ref) => {
    const inputId = id || props.name;
 
    return (
      <div className="form-field-checkbox">
        <input ref={ref} type="checkbox" id={inputId} {...props} />
        <label htmlFor={inputId}>{label}</label>
        {error && <span className="error">{error.message}</span>}
      </div>
    );
  }
);
 
FormCheckbox.displayName = 'FormCheckbox';

Multi-Step Forms

// components/forms/multi-step-form.tsx
import { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
 
const steps = [
  { id: 'personal', title: 'Personal Info', component: PersonalStep },
  { id: 'address', title: 'Address', component: AddressStep },
  { id: 'payment', title: 'Payment', component: PaymentStep },
];
 
export function MultiStepForm() {
  const [currentStep, setCurrentStep] = useState(0);
 
  const methods = useForm<CheckoutForm>({
    resolver: zodResolver(checkoutSchema),
    mode: 'onChange',
  });
 
  const { trigger, handleSubmit } = methods;
 
  const nextStep = async () => {
    // Validate current step fields before proceeding
    const fieldsToValidate = getStepFields(currentStep);
    const isValid = await trigger(fieldsToValidate);
 
    if (isValid) {
      setCurrentStep((prev) => Math.min(prev + 1, steps.length - 1));
    }
  };
 
  const prevStep = () => {
    setCurrentStep((prev) => Math.max(prev - 1, 0));
  };
 
  const onSubmit = async (data: CheckoutForm) => {
    await processCheckout(data);
  };
 
  const CurrentStepComponent = steps[currentStep].component;
 
  return (
    <FormProvider {...methods}>
      <form onSubmit={handleSubmit(onSubmit)}>
        {/* Progress indicator */}
        <div className="steps">
          {steps.map((step, index) => (
            <div
              key={step.id}
              className={index <= currentStep ? 'active' : ''}
            >
              {step.title}
            </div>
          ))}
        </div>
 
        {/* Current step content */}
        <CurrentStepComponent />
 
        {/* Navigation */}
        <div className="actions">
          {currentStep > 0 && (
            <button type="button" onClick={prevStep}>
              Back
            </button>
          )}
 
          {currentStep < steps.length - 1 ? (
            <button type="button" onClick={nextStep}>
              Next
            </button>
          ) : (
            <button type="submit">Complete</button>
          )}
        </div>
      </form>
    </FormProvider>
  );
}
 
// Step component example
function PersonalStep() {
  const { register, formState: { errors } } = useFormContext<CheckoutForm>();
 
  return (
    <div>
      <FormInput label="Name" error={errors.name} {...register('name')} />
      <FormInput label="Email" error={errors.email} {...register('email')} />
    </div>
  );
}

Dynamic Form Fields

import { useFieldArray, useForm } from 'react-hook-form';
 
interface FormData {
  users: { name: string; email: string }[];
}
 
function DynamicFieldsForm() {
  const { register, control, handleSubmit, formState: { errors } } = useForm<FormData>({
    defaultValues: {
      users: [{ name: '', email: '' }],
    },
  });
 
  const { fields, append, remove } = useFieldArray({
    control,
    name: 'users',
  });
 
  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {fields.map((field, index) => (
        <div key={field.id} className="user-row">
          <input
            {...register(`users.${index}.name`)}
            placeholder="Name"
          />
          {errors.users?.[index]?.name && (
            <span>{errors.users[index].name.message}</span>
          )}
 
          <input
            {...register(`users.${index}.email`)}
            placeholder="Email"
          />
          {errors.users?.[index]?.email && (
            <span>{errors.users[index].email.message}</span>
          )}
 
          <button
            type="button"
            onClick={() => remove(index)}
            disabled={fields.length === 1}
          >
            Remove
          </button>
        </div>
      ))}
 
      <button type="button" onClick={() => append({ name: '', email: '' })}>
        Add User
      </button>
 
      <button type="submit">Submit</button>
    </form>
  );
}

Dependent Fields

function DependentFieldsForm() {
  const { register, watch, setValue } = useForm<FormData>();
 
  const country = watch('country');
 
  // Reset state when country changes
  useEffect(() => {
    setValue('state', '');
  }, [country, setValue]);
 
  const states = country ? getStatesForCountry(country) : [];
 
  return (
    <form>
      <select {...register('country')}>
        <option value="">Select country...</option>
        <option value="US">United States</option>
        <option value="CA">Canada</option>
      </select>
 
      <select {...register('state')} disabled={!country}>
        <option value="">Select state...</option>
        {states.map((state) => (
          <option key={state.code} value={state.code}>
            {state.name}
          </option>
        ))}
      </select>
    </form>
  );
}

Form with API Integration

import { useMutation } from '@tanstack/react-query';
 
function CreateUserForm() {
  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: { errors },
  } = useForm<CreateUserForm>({
    resolver: zodResolver(createUserSchema),
  });
 
  const mutation = useMutation({
    mutationFn: createUser,
    onSuccess: () => {
      reset();
      toast.success('User created!');
    },
    onError: (error: ApiError) => {
      // Handle field-specific errors from API
      if (error.errors) {
        Object.entries(error.errors).forEach(([field, message]) => {
          setError(field as keyof CreateUserForm, { message });
        });
      } else {
        setError('root', { message: error.message });
      }
    },
  });
 
  return (
    <form onSubmit={handleSubmit((data) => mutation.mutate(data))}>
      {errors.root && <div className="error">{errors.root.message}</div>}
 
      <FormInput label="Name" error={errors.name} {...register('name')} />
      <FormInput label="Email" error={errors.email} {...register('email')} />
 
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? 'Creating...' : 'Create User'}
      </button>
    </form>
  );
}

Debounced Validation

import { useCallback } from 'react';
import debounce from 'lodash/debounce';
 
function UsernameForm() {
  const { register, setError, clearErrors } = useForm<FormData>();
 
  const checkUsername = useCallback(
    debounce(async (username: string) => {
      if (username.length < 3) return;
 
      const exists = await checkUsernameExists(username);
      if (exists) {
        setError('username', { message: 'Username already taken' });
      } else {
        clearErrors('username');
      }
    }, 500),
    [setError, clearErrors]
  );
 
  return (
    <form>
      <input
        {...register('username', {
          onChange: (e) => checkUsername(e.target.value),
        })}
      />
    </form>
  );
}

Form Styling with Tailwind

function StyledForm() {
  const { register, formState: { errors } } = useForm<FormData>();
 
  return (
    <form className="space-y-4">
      <div>
        <label className="block text-sm font-medium text-gray-700">
          Email
        </label>
        <input
          type="email"
          {...register('email')}
          className={`
            mt-1 block w-full rounded-md shadow-sm
            ${errors.email
              ? 'border-red-300 focus:border-red-500 focus:ring-red-500'
              : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500'
            }
          `}
        />
        {errors.email && (
          <p className="mt-1 text-sm text-red-600">{errors.email.message}</p>
        )}
      </div>
 
      <button
        type="submit"
        className="w-full rounded-md bg-blue-600 px-4 py-2 text-white
                   hover:bg-blue-700 disabled:opacity-50"
      >
        Submit
      </button>
    </form>
  );
}

Best Practices

  1. Use noValidate - Disable browser validation, let React Hook Form handle it
  2. Show loading states - Disable submit button and show spinner
  3. Handle API errors - Map server errors to form fields
  4. Accessibility - Use labels, aria attributes, and error announcements
  5. Reset on success - Clear form after successful submission
  6. Optimistic updates - Show success state before API confirms