Mike Varela
Mike Varela

Reputation: 527

NextJS, Zod and React Hook Form - Controlled and Uncontrolled Components

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,
  });

Upvotes: 0

Views: 913

Answers (1)

Mubashir Waheed
Mubashir Waheed

Reputation: 364

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

Upvotes: 0

Related Questions