0

I'm using NextJS 14, Zod and ShadCN UI which uses React-Hook-Form.

I'm in a scenario where I'm creating a form that can be used for both 'create' and 'update'. I pass values into the form and set defaultValues with those -or- empty strings for the 'create' form. But, as some of the fields are mandatory, an empty string is still a value and sidesteps the normal form field validation. On a text input I can add a .min(1, {message: 'some message'}) but I also have a dropdown that I'm struggling on to enforce a mandatory value. Ideally I can leave the ZOD schema clean, without the .optional() addition and the form will manage necessary fields for me.

Recently I decided to set the default values for mandatory fields to 'undefined' and this solved my mandatory fields issue, but when entering data into the one of these fields I get an error letting me know that the form is moving from uncontrolled to controlled - due to 'undefined' as a starting value.

Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen.

What's the best way to accomplish this and still have mandatory fields?

--- ZOD SCHEMA ---

    export const AddressFormSchema = z.object({
  form_type: z.string().optional(),
  id: z.string().optional(),
  type: z.string(),
  line1: z.string().trim().toLowerCase(),
  line2: z.string().trim().toLowerCase().optional(),
  city: z.string().trim().toLowerCase().optional(),
  state: z.string().optional(),
  postal_code: z
    .union([
      z.string().length(0, {
        message: "No more than 10 digits",
      }),
      z.string().trim().max(10),
    ])
    .optional(),
  agency_id: z.string().optional(),
  contact_id: z.string().optional(),
  supplier_id: z.string().optional(),
  created_at: z.string().optional(),
  updated_at: z.string().optional(),
});

-FORM PAGE---

    const { update } = props;

  const defaultValues = {
    form_type: update ? "update" : "create",
    id: update ? props.address?.id : undefined,
    type: update ? props.address?.type : undefined,
    line1: update ? props.address?.line1 : undefined,
    line2: update ? props.address?.line2 || "" : "",
    city: update ? props.address?.city || "" : "",
    state: update ? props.address?.state || "" : "",
    postal_code: update ? props.address?.postal_code || "" : "",
  };

  const form = useForm<AddressFormSchemaType>({
    resolver: zodResolver(AddressFormSchema),
    defaultValues: defaultValues,
  });

1 Answer 1

0

A form which can be used for both create and update use can conditionally render form fields specific to the operation and can add custom errors as well.

In order to achieve this you can use discriminatedUnion and merge

Here is a sample schema

// common fields for "create" and "update"
const BaseAddressSchema = z.object({
  street: z.string().min(3).max(255),
  city: z.string().min(3).max(255),
  state: z.string().min(3).max(255),
});

const AddressSchema = z.discriminatedUnion("form_type", [
  z
    .object({
      form_type: z.literal("create"),
      supplier_id: z.string().min(3).max(255), // validation only applied when "create" value selected in dropdown
    })
    .merge(BaseAddressSchema),
  z
    .object({
      form_type: z.literal("update"),
     // here you can add "update" specific fields
    })
    .merge(BaseAddressSchema),
]);

// in order to add validation to the dropdown eg(no default value present and value must be selected) you can add here
const CustomAddressSchema = z
  .object({
    form_type: z.string().min(1, "Form Type is required"),
  })
  .and(AddressSchema);

In the component you can something like this

  const {
    register,
    watch,
    handleSubmit,
    formState: { errors },
  } = useForm<any>({
    resolver: zodResolver(CustomAddressSchema ),
    defaultValues: defaultValues,
  });
  const form_type_value = watch("form_type");

...
// conditionally render a certain field based on the dropdown

      {form_type_value == "create" && (
        <div>
          <label className="mr-2">Supplier id</label>
          <input
            className="border-[1px] border-black"
            type="text"
            {...register("supplier_id")}
            placeholder="supplier Id"
          />
          {/* @ts-ignore */}
          <p>{errors.supplier_id && errors?.supplier_id?.message}</p>
        </div>
      )}

Here is a working replit https://replit.com/@MubashirWaheed/zodDiscriminatedUnion#app/page.tsx

Sign up to request clarification or add additional context in comments.

3 Comments

Thanks so much. Hadn’t thought about that. So based on this example the issue with default values possibly being undefined won’t be a thing?
@MikeVarela instead of using undefined for the values you can use '' as the value for the fields and then add validation for the fields eg min, max etc
@MikeVarela If this solved the problem please accept the answer

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.