I'm using React Hook Form
with Zod
validation to implement an OTP (One-Time Password) form where users input a 6-digit OTP, with each digit in a separate input field. I'm using z.object
to validate that the OTP consists of exactly 6 numeric digits, and I expect validation errors to display when incorrect input is provided or when less than 6 input is submitted .
Here's my otpSchema
and the relevant code:
const otpSchema = z.object({
otp: z
.array(
z.string().regex(/^\d$/, 'Each input must be a single digit'), // Ensure it is a number
)
.length(6, 'OTP must consist of exactly 6 digits')
.nonempty({
message: "Can't be empty!",
}),
});
const {
register: verifyOtpRegister, //unused
handleSubmit: verifyOtpSubmit,
trigger: verifyOtpTrigger,
control,
getValues: getVerifyOtpValues,
formState: { errors: verifyOtpErrors },
} = useForm({
resolver: zodResolver(otpSchema),
});
const onVerifyOtp = (data?: any) => {
console.log({ error: verifyOtpErrors.otp });
console.log({ data });
verifyOtp();
};
I'm rendering 6 separate input fields for the OTP, like this:
{[...Array(6)].map((_, index) => (
<Grid item key={index}>
<Controller
name={`otp[${index}]`}
control={control}
render={({ field }) => (
<TextField
{...field}
id={`otp-input-${index}`}
inputMode="numeric"
inputProps={{
maxLength: 1,
pattern: '[0-9]*',
}}
onChange={(e) => {
console.log(e);
handleInputChange(e, index, field);
}}
onKeyDown={(e) => handleKeyDown(e, index)}
value={field.value || ''}
autoComplete="off"
sx={{ width: 40, textAlign: 'center' }}
/>
)}
/>
</Grid>
))}
<FormHelperText error>
{`${verifyOtpErrors?.otp?.message || ''}`}
</FormHelperText>
<button
type="submit"
className="bg-blue-500 text-white py-2 px-4 rounded mt-4"
onClick={() => {
console.log(verifyOtpErrors?.otp);
verifyOtpSubmit(onVerifyOtp);
}}
>
Verify OTP
</button>
The issue:
errors.otp
object is logged as undefined, and no error is shown in the FormHelperText
.What I've tried:
console.log(verifyOtpErrors?.otp)
in the onVerifyOtp
function to debug, but verifyOtpErrors?.otp
is always undefined for invalid input.
Used verifyOtpTrigger('otp')
to manually trigger validation, but it still doesn't populate the error messages and is undefined.
Ensured that the name in Controller matches the schema (otp[index])
.I'm using React Hook Form
with Zod
validation to implement an OTP (One-Time Password) form where users input a 6-digit OTP, with each digit in a separate input field. I'm using z.object
to validate that the OTP consists of exactly 6 numeric digits, and I expect validation errors to display when incorrect input is provided or when less than 6 input is submitted .
Here's my otpSchema
and the relevant code:
const otpSchema = z.object({
otp: z
.array(
z.string().regex(/^\d$/, 'Each input must be a single digit'), // Ensure it is a number
)
.length(6, 'OTP must consist of exactly 6 digits')
.nonempty({
message: "Can't be empty!",
}),
});
const {
register: verifyOtpRegister, //unused
handleSubmit: verifyOtpSubmit,
trigger: verifyOtpTrigger,
control,
getValues: getVerifyOtpValues,
formState: { errors: verifyOtpErrors },
} = useForm({
resolver: zodResolver(otpSchema),
});
const onVerifyOtp = (data?: any) => {
console.log({ error: verifyOtpErrors.otp });
console.log({ data });
verifyOtp();
};
I'm rendering 6 separate input fields for the OTP, like this:
{[...Array(6)].map((_, index) => (
<Grid item key={index}>
<Controller
name={`otp[${index}]`}
control={control}
render={({ field }) => (
<TextField
{...field}
id={`otp-input-${index}`}
inputMode="numeric"
inputProps={{
maxLength: 1,
pattern: '[0-9]*',
}}
onChange={(e) => {
console.log(e);
handleInputChange(e, index, field);
}}
onKeyDown={(e) => handleKeyDown(e, index)}
value={field.value || ''}
autoComplete="off"
sx={{ width: 40, textAlign: 'center' }}
/>
)}
/>
</Grid>
))}
<FormHelperText error>
{`${verifyOtpErrors?.otp?.message || ''}`}
</FormHelperText>
<button
type="submit"
className="bg-blue-500 text-white py-2 px-4 rounded mt-4"
onClick={() => {
console.log(verifyOtpErrors?.otp);
verifyOtpSubmit(onVerifyOtp);
}}
>
Verify OTP
</button>
The issue:
errors.otp
object is logged as undefined, and no error is shown in the FormHelperText
.What I've tried:
console.log(verifyOtpErrors?.otp)
in the onVerifyOtp
function to debug, but verifyOtpErrors?.otp
is always undefined for invalid input.
Used verifyOtpTrigger('otp')
to manually trigger validation, but it still doesn't populate the error messages and is undefined.
Ensured that the name in Controller matches the schema (otp[index])
.To ensure your validation works as expected, I recommend using the refine method in your validation schema. This approach allows you to implement more complex and customized validation techniques. Also instead of manually triggering validation with verifyOtpTrigger('otp'), it's generally more efficient to use handleSubmit for form validation.
Here’s an example of how you can implement basic otp form:
import { NumericFormat } from "react-number-format";
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { Button, FormHelperText, Grid, TextField } from "@mui/material";
import { defaultValues, otpSchema, OtpValues } from "./otp-form.configs";
export const OtpForm = () => {
const form = useForm<OtpValues>({
defaultValues,
resolver: zodResolver(otpSchema),
});
const { fields } = useFieldArray<OtpValues>({
control: form.control,
name: "otp",
});
const errors = form.formState.errors;
const verifyOtpCode = (values: OtpValues): void => {
console.log(values);
};
return (
<form onSubmit={form.handleSubmit(verifyOtpCode)}>
<Grid container={true}>
{fields.map((field, index) => (
<Grid item={true} key={field.id}>
<Controller
name={`otp.${index}.value`}
control={form.control}
render={({ field: { ref, onChange, ...field } }) => (
<NumericFormat
customInput={TextField}
{...field}
inputRef={ref}
inputProps={{ maxLength: 1 }}
size="small"
onValueChange={({ floatValue }) =>
onChange(floatValue ?? null)
}
sx={{ width: 40 }}
/>
)}
/>
</Grid>
))}
</Grid>
{errors?.otp?.root && (
<FormHelperText error={true}>{errors.otp.root.message}</FormHelperText>
)}
<Button type="submit" variant="contained">
Verify OTP
</Button>
</form>
);
};
import { z } from "zod";
// TODO: move to the /shared/error-messages/otp.messages.ts
const OTP_CODE_INVALID = "Please provide a valid OTP code.";
export const otpSchema = z.object({
otp: z
.array(z.object({ value: z.number().nullable() }))
// Using refine is important here because we want to return only a single error message in the array of errors.
// Without it, we would receive individual errors for each of the 6 items in the array.
.refine((codes) => codes.every((code) => code.value !== null), OTP_CODE_INVALID),
});
export type OtpValues = z.infer<typeof otpSchema>;
export const defaultValues: OtpValues = {
otp: new Array(6).fill({ value: null }),
};