I have a form managed by react-hook-form, and want the validation to be triggered onBlur. The validation rules are defined with a Zod schema.
The problem is that the validation for some reason is not being triggered by the first blurring event, but only after a succession of blur and then changing the value of the input.
For example, I have an email field that can have as a valid value only a valid email string or an empty string. If I insert an invalid email string (say ‘someone@’) and then blur – no error is visible, and the error is undefined. If I then change the value (add/delete a character) then the error is applied correctly and as expected by the Zod rules.
I am unable to reproduce the bug in a sandbox, even when adding complexities such as a tooltip that measures the input field to check for overflowing, so it must be due to some more remote interaction that I am missing.
But maybe someone can have an idea where this sort of bug can come from based on my description of the error behavior and the code below, or suggest a way to bypass it:
export const contactSchema = z.object({
email: z.union([z.string().email(), z.literal("")]),
...
});
const defaultValues = {
email: "",
...
};
const {
register,
control,
getValues,
trigger,
formState: { errors, isDirty },
clearErrors,
setError,
setValue,
} = useForm<PartyFormType>({
defaultValues,
mode: 'onBlur',
resolver: zodResolver(contactSchema),
});
const emailValue = useWatch({
name: "email",
control,
});
An input, in a simplified version, looks something like this:
const extendedRegister = register('email', {
onBlur: e => {
console.log('blur!', 'error', errors.email?.message);
},
});
return (
<Wrapper>
<Tooltip text={emailValue} ... />
<Input {...extendedRegister} ... />
{!!errors.email?.message && <ErrorMsg>errors.email?.message</ErrorMsg>}
<Wrapper>
);
The console.log(‘blur!’, ‘error’, error) is being executed, and the error is undefined even when it should be defined. The error only gets visible after a subsequent change.
If I try to bypass the bug like so:
const extendedRegister = register('email', {
onBlur: async e => {
const isValid = await trigger('email');
console.log('blur!', 'error', errors.email?.message, 'isValid', isValid);
if (!isValid) {
setError('email', {
type: 'manual',
message: 'something',
});
}
},
});
It doesn’t help – even when isValid is false and setError is supposed to be called – the error is not being updated until after a further change of the input (in which case the error message is, as expected, ‘something’).
Any Idea what might be causing this behavior?
2
Answers
OK,
I managed to solve the issue though I don't understand it fully. The hint came from the docs for formState, where it is stated that formState is a Proxy and that care should be taken with destructuring etc.
In my actual app, I call useForm in a wrapper component:
I noticed that when logging the errors in FormWrapper, errors where logged on every blur event (expected, as the mode is onBlur). But if I log the errors in ActualForm, no re-render and logging happens following blur events.So the errors object doesn't trigger a re-render when it changes, but the larger formState object does.
So the solution is to destructure the errors in , and to pass formState as a prop:
This solves the problem. Can't say I understand the proxy behavior though...
What is going on is that you have a bit of a race condition.
errors
is stateful, react hook form even has given us a hint as such because it’s available onformState
.You’re running into a very common issue with React. When state updates, the new value is not available until the next render of a component. This often manifests when someone has a function that updates state then they immediately log/read state in the same function and find the state has not updated. In this way state in react is "async" but not in the normal js async way where we can
await
a state update. We need to wait for the next render of the component, which is one of the thingsuseEffect
does for us.In the case of your code two things are happening.
onBlur
is running andformState
is updating. Even though they run at the same time, the change informState
will not be available until the next render, while the onBlur is running in this render. So it makes total sense that initial run ofonBlur
would read the state oferrors
as undefined, because on the render thatonBlur
fireserrors
has not yet been set. To work around this, you can instead puterrors
in the dependency array of auseEffect
and that will fire whenerrors
gets a value, Don’t use the onBlur event to read state. It will always be behind by 1 state update.