Validating Dependent Fields with zod and react-hook-form
TimJ
Posted on June 3, 2023
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:
<Autocomplete
options={["Email", "Phone"]}
sx={{ width: 245 }}
renderInput={(params) => (
<TextField {...params} required label={"Preferred Contact Method"} />
)}
/>
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;
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),
});
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>
)}
/>
There are a few nuances here, let's break things down:
- We're passing the
value
andonChange
to theAutocomplete
component. We can't use theonChange
directly. We don't care about theevent
of the callback, only theitem
, and unfortunately MUI doesn't infer the types for us. Using TypeScript, we extract the union type from our array ofcontactMethods
viatypeof contactMethods[number]
, but we also need to includenull
(more on that later). Then we can pass that into theonChange
fromreact-hook-form
'sController
in the callback. - A few props
react-hook-form
uses to control the form need to be passed to theTextField
itself, namely:error
,onBlur
andref
. This allowsreact-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
andFormHelperText
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",
}),
});
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',
}),
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,
},
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",
}),
});
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"],
});
}
});
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:
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]);
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'}
/>
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 (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
Email
when the form is submitted, we know that theemail
field must be defined, and thephone
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,
}),
]
);
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;
}
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
zod
andreact-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.
Posted on June 3, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.