use-form
use-form copied to clipboard
RFC: add the possibility to validate with Zod schema
The goal of this issue is finding the best way to add form validation with Zod.
Why using Zod for form validation
Zod is schema declaration and validation library containing a lot of built in validators.
It makes also combining several validators easily and we can setup generic error message and set specific error messages if needed.
Suggested update
I think we can add Zod schema validation without any breaking change by:
- in
useForm, add a second parametervalidationSchemaof typeZodType<Values>
~It let the user use the old validation API by field. But validationSchema will overwrite validate set by field.~
Edit: To make the API opinionated and avoid user to think about different way to validate and have inconsistencies in the codebase, we'll remove the current API (and create a breaking change).
How to handle different validation use case we have at Swan:
Basic validation
const validationSchema = z.object({
name: z.string().min(1, 'Required'), // required field
surname: z.string(), // optional field
age: z.number().min(18), // validate a field containing a number
email: z.string().min(1, 'Required').email(),
})
Custom validation
const validationSchema = z.object({
IBAN: z.custom<string>((value) => isIban(value)),
})
Edit:
To be able to use reusable custom validation without rewrite the error message each time, we'll use superRefine like this:
// function we could export in a separate file as `validation.ts` to be able to import in several forms
const refineIban = (value: string, ctx: z.RefinementCtx) => {
if (!isValidIban(value)) {
ctx.addIssue({
code: "custom",
message: "Invalid IBAN",
});
}
};
const schema = z.object({
iban: z.string().superRefine(refineIban),
});
Validation of a field depending on another field
source: https://medium.com/@mobile_44538/conditional-form-validation-with-react-hook-form-and-zod-46b0b29080a3
const validationSchema = z.object({
firstName: z.string(),
lastName: z.string(),
}).superRefine(({ firstName, lastName }, ctx) => {
// Makes last name required only if first name is provided
if (firstName.length > 0 && lastName.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Last name is required",
path: ["lastName"],
})
}
})
Async validation of a field
const isIban = (value: unknown) => true
const validateIbanOnServerSide = async (value: unknown) => Promise.resolve(false)
const validationSchema = z.object({
IBAN: z.custom(isIban).refine(validateIbanOnServerSide, "This IBAN doesn't exist")
})
Error messages
By default Zod generate error messages depending on the schema declaration, but they are only in english and might not be user friendly.
First way (not recommanded): set error message in schema declaration for each field
The technically easiest way to fix this is setting custom message in all validators but it will be very painful to maintain and if we miss one case, the user will see error messages with a bad ux-writting.
Better way but not idea: Global error map
https://zod.dev/ERROR_HANDLING?id=global-error-map
Zod gives the possibility to set ErrorMap to transform default error message to translated messages with a custom ux-writting.
We can set it globally but if we use Zod for other kind of validation this isn't ideal.
Even better solution: add the possibility to set error map in react-ux-form
https://zod.dev/ERROR_HANDLING?id=contextual-error-map
Zod gives the possibiliy to set error map when we parse the input. As parsing will be done in react-ux-form, I think we could expose a function like setZodErrorMap giving the possibility to set error map for all our forms (we could call this function in utils/i18n.ts for example)
import { setZodErrorMap } from "react-ux-form";
const formErrorMaps = {
en: {},
fr: {},
de: {},
// ...
}
setZodErrorMap(formErrorMaps[locale])
After thinking about validation with Zod I thought about partial validation.
Once again Zod is amazing and provide a very easy way to get only the validator of a specific key like this:
const validationSchema = z.object({
firstName: z.string(),
lastName: z.string(),
})
const result = await validationSchema.shape.firstName.safeParseAsync(values.firstName)
But there is one limitation: if we use superRefine to create validation depending on another field, shape isn't available anymore and it makes sense because we need all values to validate 1 single field.
At the moment the only idea I had is:
1️⃣ if we don't use superRefine for validation, we use validationSchema.shape.${fieldName} exactly as we did with validate parameter
2️⃣ if we use superRefine we always validate all fields with those caveats
- if there are async validation, we should provide a way to memoize/dedupe, otherwise each validation run by any change will take too much time
- we'll not be able to use different strategy for each field because we must validate all fields together
@Epimodev I'm not sure about the superRefine API. Executing validation of all fields, all the time could be really expensive.
Maybe we could replace this:
schema: z
.object({
firstName: z.string(),
lastName: z.string(),
})
.superRefine(({ firstName, lastName }, context) => {
// Makes last name required only if first name is provided
if (firstName.length > 0 && lastName.length === 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
message: "Last name is required",
});
}
})
With an API like this:
// values is a Proxy object (or it could be a function to get the value)
schema: values =>
z.object({
firstName: z.string(),
lastName: z
.string()
.refine(
value => values.firstName.length > 0 && value.length === 0,
"Last name is required",
),
})
Yes I agree revalidate all fields will be expensive and might make inputs laggy.
But I don't understand how your solution avoid this problem. Because if we change firstName, how can we know we should also revalidate only lastName and not other fields?
@Epimodev Hmmm, indeed this does not solve the issue either.
I checked Zod returns different type of object if we add refine:
z.string()returnsZodStringz.string().refine()returnsZodEffects<z.ZodString, string, string>So we could know what field depends on other fields or not. But this isn't perfect in case we have several refine depending on different values.
According to last discussion we had about validation depending on other values, maybe the best solution will be adding a new param validationDependency which is an array of other fields names. And in the case I think it will be better to keep validate in field config like this:
const {} = useForm({
firstName: {
initialValue: "",
validationDependency: ["lastName"],
validate: ({ lastName }) => z.string().refine(
value => values.firstName.length > 0 && value.length === 0,
"First name is required", // only if last name is empty
),
},
lastName: {
initialValue: "",
}
})
The challenge here is making validationDependency type depending on other fields and validate param typed depending on validationDependency.
What do you think?