Use React Hook Forms with Zod Validation, Part Two: Using FormProvider

In part one of this series, I showed how React Hook Form paired with Zod can simplify form handling and validation. These techniques can save development time and reduce complexity. In part two, we’ll look at error handling and how to share form state across nested components without prop drilling by using FormProvider and useFormContext.

Breaking Down Complex Forms with FormProvider

When working with form, they can become complex very quickly with multipl sections, nested components, and shared validation logic. Let’s explore how to architect scalable forms using React Hook Forms’ FormProvider and useFormContext to avoid prop drilling and create maintainable form components.

Custom Hook Pattern

Create a custom hook that wraps React Hook Forms with Zod validation for consistent patterns across your application:


type ResolvedObjectOutput =
  output extends Record<string, unknown> ? output : never;

export type UseStandardFormProps = Omit<
  UseFormProps,
  "resolver"
>;

export function useStandardForm<
  T extends ZodType,
  O extends ResolvedObjectOutput = ResolvedObjectOutput,
>(schema: T, props: UseStandardFormProps): UseFormReturn {
  return useForm({ ...props, resolver: standardSchemaResolver(schema) });
}

This pattern ensures that every form in your application has consistent validation behavior and type safety.

Form Architecture with Multi-Step Form

// Main form component with FormProvider
const MultiStepForm = () => {
  const formMethods = useFlowForm(multiStepFormSchema, {
    mode: "onChange",
    defaultValues: {
      personalInfo: {
        firstName: "",
        lastName: "",
        email: "",
        phone: "",
      },
      addressInfo: {
        street: "",
        city: "",
        state: "",
        zipCode: "",
      },
      preferences: {
        newsletter: false,
        notifications: true,
      },
    },
  });

  return(
    <FormProvider {...formMethods}>
      <form onSubmit={formMethods.handleSubmit(onSubmit)}>
        <MultiStepFormContent />
      </form>
    </FormProvider>
); }; 

// Inner form component that uses context 
const MultiStepFormContent = () => { 
   const { watch, control } = useFormContext(); 

// Access form values directly from context 
   const personalInfo = watch("personalInfo"); 
   const addressInfo = watch("addressInfo"); 
   
   return (
     <div>
      <PersonalInfoSection />
      <AddressInfoSection />
      <PreferencesSection />
      <FormNavigation />
    </div>
   ); };

Nested Component Patterns

Field Components with Context

Create reusable field components that access form context:
interface LabeledTextFieldProps {
   label: string;
   name: string;
   isOptional?: boolean;
   placeholder?: string;
}

export const LabeledTextField = ({
 label,
 name,
 isOptional = false,
 placeholder,
}: LabeledTextFieldProps) => {
  const { register, formState: { errors } } = useFormContext();
  const error = get(errors, name);

  return (
   <div className="form-field">
     <label className="form-label">
       {label}
       {isOptional && <span className="optional">(optional)</span>}
     </label>
     <input
       {...register(name)}
       placeholder={placeholder}
       className={`form-input ${error ? 'error' : ''}`}
     />
    {error && <span className="error-message">{error.message}</span>}
  </div>
);
};

Complex Field Components

Handle complex input types like phone numbers with validation:
interface PhoneNumberFieldProps {
name: string;
label: string;
countryCode?: string;
}

export const PhoneNumberField = ({ name, label, countryCode = "+1" }: PhoneNumberFieldProps) => {
  const { setValue, watch, formState: { errors } } = useFormContext();
  const error = get(errors, name);

  const handlePhoneChange = (value: string, phoneInfo: PhoneInfo) => {
  const phoneData = {
    fullNumber: phoneInfo.fullNumber,
    nationalNumber: phoneInfo.nationalNumber,
    countryCode: phoneInfo.countryCode,
    formattedValue: value,
  };

  setValue(name, phoneData, { shouldValidate: true });
};

return (
  <div className="form-field">
    <label className="form-label">{label}</label>
    <PhoneInput
    country={countryCode}
    onChange={handlePhoneChange}
    className={`phone-input ${error ? 'error' : ''}`}
   />
   {error && <span className="error-message">{error.message}</span>}
  </div>
);
};

Error Handling

Implement error handling patterns that manage both client-side and server-side validation errors:


const useFormErrorHandler = () => {
  const { setError, clearErrors } = useFormContext();
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [serverError, setServerError] = useState(null);
  
  const handleSubmit = async (data: FormData) => {
    setIsSubmitting(true);
    setServerError(null);
    clearErrors();
    
    try {
      const response = await submitForm(data);
      
      if (response.success) {
        showSuccessMessage("Form submitted successfully!");
        return { success: true };
      } else {
        // Handle validation errors
        if (response.errors) {
          Object.entries(response.errors).forEach(([field, message]) => {
            setError(field as any, {
              type: "server",
              message: message as string,
            });
          });
        }
        
        // Handle general server errors
        if (response.message) {
          setServerError(response.message);
        }
        
        return { success: false, errors: response.errors };
      }
    } catch (error) {
      const errorMessage = error instanceof Error 
        ? error.message 
        : "An unexpected error occurred";
      
      setServerError(errorMessage);
      return { success: false, error: errorMessage };
    } finally {
      setIsSubmitting(false);
    }
  };
  
  return {
    handleSubmit,
    isSubmitting,
    serverError,
  };
};

 

As your application grows, these patterns provide a solid foundation that scales with your needs. Whether you’re building simple contact forms or complex enterprise applications, the combination of FormProvider, robust error handling, and custom form hooks creates a development experience that’s both powerful and maintainable.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *