Michal Tal-Socher
Michal Tal-Socher

Reputation: 1

react-hook-form validation onBlur doesn't function properly

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?

Upvotes: 0

Views: 1557

Answers (2)

Michal Tal-Socher
Michal Tal-Socher

Reputation: 1

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 some consequences of this fact are mentioned.

In my actual app, I call useForm in a wrapper component:

const contactSchema = z.object({
  email: z.union([z.string().email(), z.literal("")]),
...
});

const defaultValues = {
  email: "",
...
};

const FormWrapper = (...) => {
...

const {
      register,
      control,
      formState: { errors, isDirty },
    } = useForm<PartyFormType>({
      defaultValues,
      mode: 'onBlur',
      resolver: zodResolver(contactSchema),
    });



 console.log('errors', errors);
...

return (<ActualForm errors={errors} isDirty={isDirty} >) 

}

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 ActualForm, and to pass formState as a prop to ActualForm:

    const FormWrapper = (...) => {
    ...
    
    const {
          register,
          control,
          formState,
        } = useForm<PartyFormType>({
          defaultValues,
          mode: 'onBlur',
          resolver: zodResolver(contactSchema),
        });
    ...
    
    return (<ActualForm formState={formState} >) 
    
    }

const ActualForm = (...) => {

const {errors, isDirty} = formState;

...
}

This solves the problem. Can't say I understand the proxy behavior though...

Upvotes: 0

Scott Z
Scott Z

Reputation: 1155

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 on formState.

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 things useEffect does for us.

In the case of your code two things are happening. onBlur is running and formState is updating. Even though they run at the same time, the change in formState will not be available until the next render, while the onBlur is running in this render. So it makes total sense that initial run of onBlur would read the state of errors as undefined, because on the render that onBlur fires errors has not yet been set. To work around this, you can instead put errors in the dependency array of a useEffect and that will fire when errors gets a value, Don't use the onBlur event to read state. It will always be behind by 1 state update.

Upvotes: 0

Related Questions