Stackvora.tech

Forms in React with React Hook Form + Zod

Building robust and type-safe forms in React applications requires careful consideration of state management, validation, and error handling. While React…

Building robust and type-safe forms in React applications requires careful consideration of state management, validation, and error handling. While React provides basic mechanisms for form input, libraries like React Hook Form streamline the process significantly. Integrating Zod for schema validation further enhances type safety and developer experience, ensuring form data adheres to predefined structures before submission. This article explores the combined power of React Hook Form and Zod, providing a comprehensive guide to implementing sophisticated forms.

We'll cover the fundamental setup, common input types, array handling, and asynchronous validation, concluding with practical troubleshooting tips. This guide assumes familiarity with React fundamentals and basic JavaScript/TypeScript.

Setting Up React Hook Form with Zod

First, install the necessary packages:

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

react-hook-form provides the core hooks and components for form management. zod is our schema validation library, and @hookform/resolvers is the bridge connecting Zod schemas to React Hook Form's resolver system.

Defining Your Zod Schema

Zod schemas are JavaScript objects defining the expected shape and types of your form data. They are declarative and composable, supporting a wide range of validation rules.

import { z } from 'zod';

const loginSchema = z.object({
  email: z.string().email("Invalid email address").min(1, "Email is required"),
  password: z.string().min(8, "Password must be at least 8 characters long"),
  rememberMe: z.boolean().optional(),
});

type LoginFormData = z.infer;
// LoginFormData will be { email: string; password: string; rememberMe?: boolean; }

Here, loginSchema defines two required string fields (email, password) with specific validation rules and an optional boolean field (rememberMe). z.infer is a powerful Zod feature that extracts the TypeScript type from your schema, ensuring type safety throughout your application.

Initializing React Hook Form

Inside your functional component, use the useForm hook. This hook returns an object containing methods and state for managing your form.

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

const profileSchema = z.object({
  username: z.string().min(3, "Username must be at least 3 characters"),
  age: z.number().int("Age must be an integer").min(18, "Must be 18 or older"),
  bio: z.string().max(200, "Bio cannot exceed 200 characters").optional(),
});

type ProfileFormData = z.infer;

function ProfileForm() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({
    resolver: zodResolver(profileSchema),
    defaultValues: {
      username: '',
      age: undefined, // Or 0, depending on desired default for number inputs
      bio: '',
    }
  });

  const onSubmit = async (data: ProfileFormData) => {
    console.log("Form Data Submitted:", data);
    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 1000));
    alert(JSON.stringify(data, null, 2));
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="username">Username:</label>
        <input id="username" type="text" {...register("username")} />
        {errors.username && <p style={{ color: 'red' }}>{errors.username.message}</p>}
      </div>

      <div>
        <label htmlFor="age">Age:</label>
        <input id="age" type="number" {...register("age", { valueAsNumber: true })} />
        {errors.age && <p style={{ color: 'red' }}>{errors.age.message}</p>}
      </div>

      <div>
        <label htmlFor="bio">Bio:</label>
        <textarea id="bio" {...register("bio")} />
        {errors.bio && <p style={{ color: 'red' }}>{errors.bio.message}</p>}
      </div>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

Key elements in useForm configuration:

  • resolver: zodResolver(profileSchema): This is the core integration point. React Hook Form uses this resolver to validate your form data against the Zod schema.
  • defaultValues: Provides initial values for your form fields. It's good practice to set all fields defined in your schema, even optional ones, to avoid uncontrolled component warnings.

The register function is crucial for connecting input elements to React Hook Form. It returns an object of props (name, onChange, onBlur, ref) that you spread onto your input components. For number inputs, remember to include { valueAsNumber: true } in the register options to ensure the value is parsed as a number, not a string, before Zod validation.

handleSubmit wraps your form submission logic. It handles validation before calling your onSubmit function, passing the validated data. formState.errors contains any validation errors, and formState.isSubmitting is useful for disabling the submit button during an API call.

Handling Complex Inputs and Dynamic Fields

Select Boxes and Checkboxes

For select boxes, the value is typically a string or number. For checkboxes, React Hook Form handles the boolean state automatically.

const userSettingsSchema = z.object({
  theme: z.enum(['light', 'dark', 'system'], {
    errorMap: (issue, ctx) => {
      if (issue.code === z.ZodIssueCode.invalid_enum_value) {
        return { message: "Please select a valid theme" };
      }
      return { message: ctx.defaultError };
    },
  }),
  receiveNotifications: z.boolean(),
});

// ... inside your component
<div>
  <label htmlFor="theme">Theme:</label>
  <select id="theme" {...register("theme")}>
    <option value="">Select a theme</option>
    <option value="light">Light</option>
    <option value="dark">Dark</option>
    <option value="system">System</option>
  </select>
  {errors.theme && <p style={{ color: 'red' }}>{errors.theme.message}</p>}
</div>

<div>
  <input type="checkbox" id="receiveNotifications" {...register("receiveNotifications")} />
  <label htmlFor="receiveNotifications">Receive Notifications</label>
  {errors.receiveNotifications && <p style={{ color: 'red' }}>{errors.receiveNotifications.message}</p>}
</div>

Working with Arrays (Dynamic Fields)

React Hook Form's useFieldArray hook is designed for managing lists of inputs, such as a list of email addresses or items in an order. It provides methods like append, remove, and insert.

import { useFieldArray } from 'react-hook-form';

const projectSchema = z.object({
  projectName: z.string().min(1, "Project name is required"),
  tasks: z.array(z.object({
    taskName: z.string().min(1, "Task name is required"),
    isCompleted: z.boolean().default(false),
  })).min(1, "At least one task is required"),
});

type ProjectFormData = z.infer;

function ProjectForm() {
  const { register, control, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(projectSchema),
    defaultValues: {
      projectName: '',
      tasks: [{ taskName: '', isCompleted: false }],
    },
  });

  const { fields, append, remove } = useFieldArray({
    control,
    name: "tasks",
  });

  const onSubmit = (data: ProjectFormData) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="projectName">Project Name:</label>
        <input id="projectName" {...register("projectName")} />
        {errors.projectName && <p style={{ color: 'red' }}>{errors.projectName.message}</p>}
      </div>

      <h3>Tasks</h3>
      {fields.map((field, index) => (
        <div key={field.id} style={{ border: '1px solid #ccc', padding: '10px', margin: '10px 0' }}>
          <label htmlFor={`tasks.${index}.taskName`}>Task Name:</label>
          <input
            id={`tasks.${index}.taskName`}
            {...register(`tasks.${index}.taskName`)}
            defaultValue={field.taskName}
          />
          {errors.tasks?.[index]?.taskName && <p style={{ color: 'red' }}>{errors.tasks[index].taskName.message}</p>}

          <input
            type="checkbox"
            id={`tasks.${index}.isCompleted`}
            {...register(`tasks.${index}.isCompleted`)}
            defaultChecked={field.isCompleted}
          />
          <label htmlFor={`tasks.${index}.isCompleted`}>Completed</label>

          <button type="button" onClick={() => remove(index)}>Remove Task</button>
        </div>
      ))}
      {errors.tasks && <p style={{ color: 'red' }}>{errors.tasks.message}</p>}
      <button type="button" onClick={() => append({ taskName: '', isCompleted: false })}>Add Task</button>
      
      <button type="submit">Submit Project</button>
    </form>
  );
}

Note how register paths are dynamically generated (e.g., tasks.${index}.taskName). When using useFieldArray, you generally pass defaultValue or defaultChecked to your inputs based on field data, rather than relying solely on defaultValues in useForm for dynamically added fields.

Advanced Zod Features and Asynchronous Validation

Refining Schemas with refine and superRefine

Zod's refine and superRefine methods allow you to add custom, arbitrary validation logic that might depend on multiple fields. refine is for simple pass/fail, while superRefine offers more control over error messages and pathing.

const passwordSchema = z.object({
  password: z.string().min(8, "Password must be at least 8 characters"),
  confirmPassword: z.string().min(8, "Confirm password is required"),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords do not match",
  path: ["confirmPassword"], // Error message targets 'confirmPassword' field
});

For more complex scenarios or multiple interdependent validations, superRefine is preferred:

const dateRangeSchema = z.object({
  startDate: z.string().datetime(),
  endDate: z.string().datetime(),
}).superRefine((data, ctx) => {
  if (new Date(data.startDate) > new Date(data.endDate)) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Start date cannot be after end date",
      path: ["startDate"],
    });
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: "End date cannot be before start date",
      path: ["endDate"],
    });
  }
});

Asynchronous Validation

Sometimes validation requires an API call (e.g., checking if a username is already taken). Zod schemas can be asynchronous.

const asyncUserSchema = z.object({
  username: z.string().min(3).refine(async (value) => {
    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 500));
    const takenUsernames = ['admin', 'moderator'];
    return !takenUsernames.includes(value.toLowerCase());
  }, {
    message: "Username is already taken",
  }),
  email: z.string().email(),
});

// useForm hook needs async resolver
const form = useForm({
  resolver: zodResolver(asyncUserSchema), // ZodResolver handles async schemas automatically
  mode: "onChange", // Or "onBlur", to trigger validation earlier
});

When an async resolver is used, formState.isSubmitting will be true during the validation phase if you're using handleSubmit. You might also want to use formState.isValidating for real-time feedback during async field validation.

Form Control with Controller

For external UI libraries (e.g., Material-UI, Ant Design) that don't expose a ref prop or use a different prop for their value (e.g., value and onChange instead of name and value), React Hook Form provides the Controller component.

import { Controller } from 'react-hook-form';
// Assuming you have a DatePicker component from a UI library
// import DatePicker from 'react-datepicker'; // Example

const eventSchema = z.object({
  eventName: z.string().min(1, "Event name is required"),
  eventDate: z.date(),
});

function EventForm() {
  const { control, handleSubmit, formState: { errors } } = useForm({
    resolver: zodResolver(eventSchema),
    defaultValues: {
      eventName: '',
      eventDate: new Date(),
    },
  });

  const onSubmit = (data) => console.log(data);

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label htmlFor="eventName">Event Name:</label>
        <input id="eventName" {...control.register("eventName")} />
        {errors.eventName && <p style={{ color: 'red' }}>{errors.eventName.message}</p>}
      </div>

      <div>
        <label>Event Date:</label>
        <Controller
          name="eventDate"
          control={control}
          render={({ field }) => (
            <input // Replace with your actual DatePicker component
              type="date"
              value={field.value.toISOString().split('T')[0]} // Format date for input type="date"
              onChange={(e) => field.onChange(new Date(e.target.value))}
              onBlur={field.onBlur}
            />
            // Or for a typical DatePicker component:
            // <DatePicker
            //   selected={field.value}
            //   onChange={(date) => field.onChange(date)}
            //   onBlur={field.onBlur}
            // />
          )}
        />
        {errors.eventDate && <p style={{ color: 'red' }}>{errors.eventDate.message}</p>}
      </div>
      <button type="submit">Create Event</button>
    </form>
  );
}

The Controller component takes name and control props, and a render prop. The render prop receives a field object with properties like value, onChange, and onBlur that you can pass directly to your external component.

Common Pitfalls and Troubleshooting

  • Missing resolver: Forgetting to pass resolver: zodResolver(yourSchema) to useForm will result in no validation errors being displayed, as React Hook Form won't know how to validate.
  • Incorrect defaultValues: Ensure your defaultValues object matches the structure and types (or compatible defaults) of your Zod schema. Mismatches can cause type errors or unexpected behavior. For optional fields, provide undefined or an empty string/null.
  • valueAsNumber for Number Inputs: Always use { valueAsNumber: true } with register for type="number" inputs if your Zod schema expects a z.number(). Otherwise, the input value (which is a string) will fail number validation.
  • Controller vs. register: Understand when to use each. register is for native HTML inputs, while Controller is for integrating with third-party components that manage their own internal state.
  • Asynchronous refine/superRefine without await: If your custom validation involves async operations, ensure your refine or superRefine callback is marked async and uses await for the promise to resolve.
  • Error Message Paths with refine/superRefine: When using refine, specify a path (e.g., path: ["fieldName"]) to attach the error to a specific field. With superRefine, use ctx.addIssue({ path: ["fieldName"] }) for precise error placement.
  • React Hook Form version differences: This guide generally applies to React Hook Form v7.x. While core concepts are stable, minor API changes might occur between major versions. Always consult the official documentation for your specific version.

Back to the knowledge base · Ask the AI assistant