Search code examples
reactjsreact-hook-formzod

React Hook Form Treating Optional Fields as Required When Using Zod Resolver


I’m using React Hook Form with Zod for form validation. My schema has optional fields using .partial(), and when I manually validate the schema with safeParse(), it correctly treats the fields as optional. However, React Hook Form still considers them required and showing validation errors when they are left empty.

Key Observations:

  • Zod Works Correctly
  • Running safeParse() logs IsSuccess:: true, confirming that Zod allows optional fields.

Our Use Case:

  • We have a bank of fields in our schema.
  • We pick specific fields based on the form requirements.
  • We then make some of these fields optional dynamically.

Codesandbox

Here is the schema code:

const baseSchema = z.object({
  email: z
    .string()
    .nonempty({ message: "Email is required" })
    .email({ message: "Invalid email address" }),
  password: z
    .string()
    .nonempty({ message: "Password is required" })
    .min(6, { message: "Password should be min 6 characters" }),
  first_name: z
    .string()
    .min(1, { message: "First name is required" })
    .max(34, { message: "First name can only have 34 characters " }),
  last_name: z.string().min(1, { message: "Last name is required" }),
  phone: z
    .string()
    .max(16, { message: "Phone number can only be 16 characters long" })
    .regex(/^( )*(0|\+)(\+|0?\d)([0-9]| |[-()])*$/, {
      message: "Phone number in not correct",
    }),
  street: z.string().min(1, { message: "Street is required" }),
  house_number: z.string().min(1, { message: "House number is required" }),
});
const authSchema = baseSchema
  .pick({
    email: true,
    password: true,
    first_name: true,
    last_name: true,
    phone: true,
  })
  .partial({
    // email: true,
    // password: true,
    first_name: true,
    last_name: true,
    phone: true,
  });
const data = authSchema.safeParse({
  email: "test@test.com",
  password: "123456",
});
console.log("IsSuccess::", data.success); // true

const allOptional = authSchema;

type AuthForm = z.infer<typeof allOptional>;

Here is the component code

export default function App() {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<AuthForm>({
    resolver: zodResolver(allOptional),
    mode: "onBlur",
  });

  const onSubmit: SubmitHandler<AuthForm> = useCallback(async (value) => {
    console.log(value);
  }, []);

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="">
      <label>
        <span>First name</span>
        <input {...register("first_name")} />
        <span>{errors.first_name?.message}</span>
      </label>
      <label>
        <span>Last name</span>
        <input {...register("last_name")} />
        <span>{errors.last_name?.message}</span>
      </label>
      <label>
        <span>Phone</span>
        <input {...register("phone")} />
        <span>{errors.phone?.message}</span>
      </label>
      <label>
        <span>Email</span>
        <input {...register("email")} />
        <span>{errors.email?.message}</span>
      </label>
      <label>
        <span>Password</span>
        <input {...register("password")} />
        <span>{errors.password?.message}</span>
      </label>
      <button type="submit">Submit</button>
    </form>
  );
}


Solution

  • This is a known issue e.g. And it comes from the fact that html inputs use a empty string as default value. source

    When using optional in zod you're saying that the field can be undefined which is different from empty string "". That causes zod to validate it against the z.string schema and not the z.optional.

    The solution to this as stated in the zod docs is to use a union and accept the literal value of "".

    Now it's just a matter adding that to the schemas you want to be optional. There's many ways in achieving this. But here is a reusable solution:

    const fixHtmlFormOptionalFields = <T extends z.ZodObject<any, any>>(
      schema: T
    ): T => {
      const entries = Object.entries(schema.shape);
    
      const transformedEntries = entries.map(([key, value]) => {
        // Only transform optional schemas
        if (value instanceof z.ZodOptional) {
          return [key, z.union([value, z.literal("")])];
        }
    
        return [key, value];
      });
    
      return z.object(Object.fromEntries(transformedEntries)) as T;
    };
    
    // use this one now
    const fixedSchema = fixHtmlFormOptionalFields(authSchema);
    

    sandbox