Validating Dependent Fields with zod and react-hook-form

timwjames

TimJ

Posted on June 3, 2023

Validating Dependent Fields with zod and react-hook-form

When building forms, there are complex cases where the validation of one field may depend on the state of another. In this post, I'll implement an example of one such scenario using zod, react-hook-form and Material UI.

Consider the case where a user is providing contact information, and they have several options (email or phone number). Then they need to pick their preferred contact method. We want to validate that the user has entered the contact details for the preferred method they chose, and have other contact details be optional. We need a way to check the validity of the email or phone fields conditional on the chosen preferred contact method.

This post is the second in a series on building forms with React, you can read the previous post here.

Creating the Autocomplete Field

First, let's create the input field component using Material UI. This part will be dealing with integrating an MUI component with react-hook-form, so if you're less interested that and want to skip to the dependent validation logic, jump to # Updating the Schema with Dependent Fields.

To do this, we'll be using Autocomplete, which will give us a dropdown with a list of items we can pick. It also has some extra built-in functionality over a basic select element, that gives us the ability to type and filter for items in the list, and clear the input once selected.

MUI Autocomplete

At it's most basic, we can use the component like this:



<Autocomplete
  options={["Email", "Phone"]}
  sx={{ width: 245 }}
  renderInput={(params) => (
    <TextField {...params} required label={"Preferred Contact Method"} />
  )}
/>


Enter fullscreen mode Exit fullscreen mode

Autocomplete acts as wrapper around the base TextField, where we specify the set of possible options. If you've read the previous tutorial, you'll know what's coming next; we need to control the input using react-hook-form and update our zod schema for validation.

Pull out the set of options into a constant, since we'll be using it in a few places:



const contactMethods = ["Email", "Phone"] as const;


Enter fullscreen mode Exit fullscreen mode

Update the zod schema (you can find the code from the previous tutorial on Stackblitz) to include our new field, using a zod enum:



const formSchema = z.object({
  phone: looseOptional(mobilePhoneNumberSchema),
  email: z
    .string()
    .nonempty("Please specify an email")
    .email("Please specify a valid email"),
  preferredContactMethod: z.enum(contactMethods),
});


Enter fullscreen mode Exit fullscreen mode

Finally, add the Autocomplete component to a new react-hook-from Controller within the form (again, referring to code from the previous tutorial):



<Controller
  name="preferredContactMethod"
  control={control}
  render={({
    field: { value, onChange, onBlur, ref },
    fieldState: { error },
  }) => (
    <FormControl>
      <Autocomplete
        onChange={(
          _event: unknown,
          item: (typeof contactMethods)[number] | null
        ) => {
          onChange(item);
        }}
        value={value ?? null}
        options={contactMethods}
        sx={{ width: 245 }}
        renderInput={(params) => (
          <TextField
            {...params}
            required
            error={Boolean(error)}
            onBlur={onBlur}
            inputRef={ref}
            label={"Preferred Contact Method"}
          />
        )}
      />
      <FormHelperText
        sx={{
          color: "error.main",
        }}
      >
        {error?.message ?? ""}
      </FormHelperText>
    </FormControl>
  )}
/>


Enter fullscreen mode Exit fullscreen mode

There are a few nuances here, let's break things down:

  • We're passing the value and onChange to the Autocomplete component. We can't use the onChange directly. We don't care about the event of the callback, only the item, and unfortunately MUI doesn't infer the types for us. Using TypeScript, we extract the union type from our array of contactMethods via typeof contactMethods[number], but we also need to include null (more on that later). Then we can pass that into the onChange from react-hook-form's Controller in the callback.
  • A few props react-hook-form uses to control the form need to be passed to the TextField itself, namely: error, onBlur and ref. This allows react-hook-form to put the field in an error state, trigger revalidation on blur, and focus the input respectively.
  • Like other fields, we use MUI's FormControl and FormHelperText to render the error message.

Since we're treating this as a required field, we need to update the validation message from zod (by default it is just "Required"), Since we aren't dealing with validations on a zod type (like .nonempty()), there are a few possible errors, so we need to be explicit by defining a required_error:



const formSchema = z.object({
  phone: looseOptional(mobilePhoneNumberSchema),
  email: z
    .string()
    .nonempty("Please specify an email")
    .email("Please specify a valid email"),
  preferredContactMethod: z.enum(contactMethods, {
    required_error: "Please select a contact method",
  }),
});


Enter fullscreen mode Exit fullscreen mode

Nice, but you might be wondering... why did we specify null as a possible type of the item that's being passed to onChange? Try selecting a value, then hit the X on the right of the input to clear the selection, then loose focus of the field (remember we specified the revalidate mode of onBlur in the previous tutorial). You'll see:

Expected 'Email' | 'Phone', received null

which is not exactly user-friendly. That's because MUI sets the input to null when clearing the input, so from zod's perspective, the value is still defined. Thankfully, zod catches this as an invalid_type_error, so we just need to update our schema (unfortunately with a bit of duplication):



preferredContactMethod: z.enum(contactMethods, {
  required_error: 'Please select a contact method',
  invalid_type_error: 'Please select a contact method',
}),


Enter fullscreen mode Exit fullscreen mode

You'll also notice an error being logged to the console. That's because MUI interprets a value being undefined as indicating that the component is uncontrolled. But then when the value changes to one of the options, MUI then needs to treat it as controlled, and this can be problematic (here's why). To get around this, we can specify the default value of the component within our useForm hook as null, not undefined:



defaultValues: {
  email: '',
  phone: '',
  preferredContactMethod: null,
},


Enter fullscreen mode Exit fullscreen mode

An aside: the fact that the default/cleared value is null means that if you wanted to make this field optional, .optional() on the schema won't cover you (since that just checks if it is undefined). To solve this, you can preprocess the value to convert null to undefined. The helper function looseOptional we built in the previous tutorial does precisely that.

Updating the Schema with Dependent Fields

Nice, we have our Autocomplete field working, now we want to validate that if the user selects their preferred contact method as "Email", that they actually specify an email (ditto for "Phone").

Something you'll notice is that the validation of each field within the schema is independent of other fields in the schema. We need something else to make that validation happen... superRefine to the rescue!

superRefine allows us to apply validation on the overall z.object(), and gives us the current value of the object, and context that we can use to imperatively "inject" errors into specific fields which react-hook-form can then interpret.

First, let's make both email and phone optional by default, since they'll be required conditionally on preferredContactMethod:



const formSchema = z.object({
  phone: looseOptional(mobilePhoneNumberSchema),
  email: looseOptional(
    z
      .string()
      .nonempty("Please specify an email")
      .email("Please specify a valid email")
  ),
  preferredContactMethod: z.enum(contactMethods, {
    required_error: "Please select a contact method",
    invalid_type_error: "Please select a contact method",
  }),
});


Enter fullscreen mode Exit fullscreen mode

Then we can add the superRefine to the mix:



const formSchema = z.object({
  ...
  }),
})
.superRefine((values, context) => {
  if (
    values.preferredContactMethod === "Email" && !values.email
  ) {
    context.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Please specify an email",
      path: ["email"],
    });
  } else if (
    values.preferredContactMethod === "Phone" && !values.phone
  ) {
    context.addIssue({
      code: z.ZodIssueCode.custom,
      message: "Please specify a phone number",
      path: ["phone"],
    });
  }
});


Enter fullscreen mode Exit fullscreen mode

Try selecting an option, then hit submit. 🀯 context.addIssue allows us to add an error to whatever path we want (be aware that path is only stringly typed), using z.ZodIssueCode.custom since we aren't using any of zod's built-in errors (aka issues).

Triggering Validation Conditionally

Cool, but you may have noticed that the message only updates when hitting the submit button. That's because react-hook-from doesn't know to re-render the email or phone fields when preferredContactMethod changes. We can leverage two things from react-hook-form:

  • watch the preferredContactMethod for changes.
  • trigger validation of email and phone.

We can capture this logic as follows:



const { control, handleSubmit, watch, trigger } = useForm<FormFields>({
  ...
});

useEffect(() => {
  const subscription = watch((_value, { name }) => {
    if (name === 'preferredContactMethod') {
      void trigger(['phone', 'email']);
    }
  });

  // Cleanup the subscription on unmount.
  return () => subscription.unsubscribe();
}, [watch, trigger]);


Enter fullscreen mode Exit fullscreen mode

Now the user get immediate feedback based on their selection.

Marking Fields as Required Conditionally

We also want to set the email or phone fields as required conditional on the selected preferredContactMethod. Sure, this is captured by the schema, but this isn't visible to the underlying MUI components.

We could use watch to achieve this as well, but we can optimise our re-renders using getValues:



const { control, handleSubmit, watch, trigger, getValues } =
  useForm<FormFields>({

...

<TextField
  name="phone"
  ...
  required={getValues('preferredContactMethod') === 'Phone'}
/>


Enter fullscreen mode Exit fullscreen mode

and the equivalent for email.

And that's it, we now have a polished UX to conditionally validate different fields. πŸš€

You can find the complete source code and an interactive demo on Stackblitz.

Gotchas

If you're using superRefine in a larger form, where there are other unrelated fields to those you want to conditionally validate, you might notice some interesting behaviour. If those unrelated fields are invalid, the logic you defined in superRefine might not trigger, at least until those unrelated fields are made valid. That's because zod doesn't run superRefine until the validation within the z.object() passes, which is intentional, but can lead to an unexpected UX. A workaround is to defined those unrelated fields in a separate z.object(), then do an intersection with the other z.object() with the superRefine to create the overall schema. Props to this GitHub issue for identifying that solution.

An Aside on Discriminated Unions

You might have noticed a drawback of using superRefine in this scenario:

  • We have to loosen fields in our schema (via looseOptional), then apply conditional logic imperatively. This isn't explicitly defined in the schema itself.
  • We don't get good type information for this conditional logic. If the chosen contact method is set to Email when the form is submitted, we know that the email field must be defined, and the phone field isn't relevant. We need to write some additional logic when we submit the form to handle this. It seems like our type system isn't being fully leveraged in this case.

Introducing discriminated unions. We can rewrite the schema as follows:



const formSchema = z.discriminatedUnion(
  'preferredContactMethod',
  [
    z.object({
      preferredContactMethod: z.literal('Email'),
      email: z
        .string()
        .nonempty('Please specify an email')
        .email('Please specify a valid email'),
    }),
    z.object({
      preferredContactMethod: z.literal('Phone'),
      phone: mobilePhoneNumberSchema,
    }),
  ]
);


Enter fullscreen mode Exit fullscreen mode

Now we're taking a cleaner, more declarative approach. This also feeds into the types inferred by TypeScript:



type FormFields = {
    preferredContactMethod: "Email";
    email: string;
} | {
    preferredContactMethod: "Phone";
    phone: string;
}


Enter fullscreen mode Exit fullscreen mode

This is great, but at the time of writing, the author of zod is considering deprecating discriminatedUnions in favour of a new API. You can read more about that here.

discriminatedUnion also is somewhat constrained, where superRefine is far more general. Consider a form with fields for start and end dates, where the start date must be before the end date. A discriminatedUnion requires a specific key to determine which schema to use, which isn't possible here, and we must resort to a more imperative superRefine solution.

For those reasons, I wouldn't necessarily recommend using a discriminatedUnion over a superRefine for form validation, but it certainly has potential. I'll be keeping an eye out for some future API from zod (or some other runtime validation library) to fill this niche.

Further Reading

πŸ’– πŸ’ͺ πŸ™… 🚩
timwjames
TimJ

Posted on June 3, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related