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.

At it's most basic, we can use the component like this:
1<Autocomplete 2 options={["Email", "Phone"]} 3 sx={{ width: 245 }} 4 renderInput={(params) => ( 5 <TextField {...params} required label={"Preferred Contact Method"} /> 6 )} 7/> 8tsx
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:
1const contactMethods = ["Email", "Phone"] as const; 2tsx
Update the zod schema (you can find the code from the previous tutorial on Stackblitz) to include our new field, using a zod enum:
1const formSchema = z.object({ 2 phone: looseOptional(mobilePhoneNumberSchema), 3 email: z 4 .string() 5 .nonempty("Please specify an email") 6 .email("Please specify a valid email"), 7 preferredContactMethod: z.enum(contactMethods), 8}); 9tsx
Finally, add the Autocomplete component to a new react-hook-from Controller within the form (again, referring to code from the previous tutorial):
1<Controller 2 name="preferredContactMethod" 3 control={control} 4 render={({ 5 field: { value, onChange, onBlur, ref }, 6 fieldState: { error }, 7 }) => ( 8 <FormControl> 9 <Autocomplete 10 onChange={( 11 _event: unknown, 12 item: (typeof contactMethods)[number] | null 13 ) => { 14 onChange(item); 15 }} 16 value={value ?? null} 17 options={contactMethods} 18 sx={{ width: 245 }} 19 renderInput={(params) => ( 20 <TextField 21 {...params} 22 required 23 error={Boolean(error)} 24 onBlur={onBlur} 25 inputRef={ref} 26 label={"Preferred Contact Method"} 27 /> 28 )} 29 /> 30 <FormHelperText 31 sx={{ 32 color: "error.main", 33 }} 34 > 35 {error?.message ?? ""} 36 </FormHelperText> 37 </FormControl> 38 )} 39/> 40tsx
There are a few nuances here, let's break things down:
- We're passing the
valueandonChangeto theAutocompletecomponent. We can't use theonChangedirectly. We don't care about theeventof the callback, only theitem, and unfortunately MUI doesn't infer the types for us. Using TypeScript, we extract the union type from our array ofcontactMethodsviatypeof contactMethods[number], but we also need to includenull(more on that later). Then we can pass that into theonChangefromreact-hook-form'sControllerin the callback. - A few props
react-hook-formuses to control the form need to be passed to theTextFielditself, namely:error,onBlurandref. This allowsreact-hook-formto put the field in an error state, trigger revalidation on blur, and focus the input respectively. - Like other fields, we use MUI's
FormControlandFormHelperTextto 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:
1const formSchema = z.object({ 2 phone: looseOptional(mobilePhoneNumberSchema), 3 email: z 4 .string() 5 .nonempty("Please specify an email") 6 .email("Please specify a valid email"), 7 preferredContactMethod: z.enum(contactMethods, { 8 required_error: "Please select a contact method", 9 }), 10}); 11tsx
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):
1preferredContactMethod: z.enum(contactMethods, { 2 required_error: 'Please select a contact method', 3 invalid_type_error: 'Please select a contact method', 4}), 5tsx
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:
1defaultValues: { 2 email: '', 3 phone: '', 4 preferredContactMethod: null, 5}, 6tsx
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:
1const formSchema = z.object({ 2 phone: looseOptional(mobilePhoneNumberSchema), 3 email: looseOptional( 4 z 5 .string() 6 .nonempty("Please specify an email") 7 .email("Please specify a valid email") 8 ), 9 preferredContactMethod: z.enum(contactMethods, { 10 required_error: "Please select a contact method", 11 invalid_type_error: "Please select a contact method", 12 }), 13}); 14tsx
Then we can add the superRefine to the mix:
1const formSchema = z.object({ 2 ... 3 }), 4}) 5.superRefine((values, context) => { 6 if ( 7 values.preferredContactMethod === "Email" && !values.email 8 ) { 9 context.addIssue({ 10 code: z.ZodIssueCode.custom, 11 message: "Please specify an email", 12 path: ["email"], 13 }); 14 } else if ( 15 values.preferredContactMethod === "Phone" && !values.phone 16 ) { 17 context.addIssue({ 18 code: z.ZodIssueCode.custom, 19 message: "Please specify a phone number", 20 path: ["phone"], 21 }); 22 } 23}); 24tsx
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:
We can capture this logic as follows:
1const { control, handleSubmit, watch, trigger } = useForm<FormFields>({ 2 ... 3}); 4 5useEffect(() => { 6 const subscription = watch((_value, { name }) => { 7 if (name === 'preferredContactMethod') { 8 void trigger(['phone', 'email']); 9 } 10 }); 11 12 // Cleanup the subscription on unmount. 13 return () => subscription.unsubscribe(); 14}, [watch, trigger]); 15tsx
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:
1const { control, handleSubmit, watch, trigger, getValues } = 2 useForm<FormFields>({ 3 4... 5 6<TextField 7 name="phone" 8 ... 9 required={getValues('preferredContactMethod') === 'Phone'} 10/> 11tsx
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
loosenfields in our schema (vialooseOptional), 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
Emailwhen the form is submitted, we know that theemailfield must be defined, and thephonefield 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:
1const formSchema = z.discriminatedUnion( 2 'preferredContactMethod', 3 [ 4 z.object({ 5 preferredContactMethod: z.literal('Email'), 6 email: z 7 .string() 8 .nonempty('Please specify an email') 9 .email('Please specify a valid email'), 10 }), 11 z.object({ 12 preferredContactMethod: z.literal('Phone'), 13 phone: mobilePhoneNumberSchema, 14 }), 15 ] 16); 17tsx
Now we're taking a cleaner, more declarative approach. This also feeds into the types inferred by TypeScript:
1type FormFields = { 2 preferredContactMethod: "Email"; 3 email: string; 4} | { 5 preferredContactMethod: "Phone"; 6 phone: string; 7} 8tsx
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
- Previous post on building forms with
zodandreact-hook-form: https://timjames.dev/blog/building-forms-with-zod-and-react-hook-form-2geg - Creating Custom io-ts Decoders for Runtime Parsing
- Parse, don’t validate
- Stay tuned for a follow up post on implementing nested fields.









