skip to Main Content

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


  1. Chosen as BEST ANSWER

    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:

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

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


  2. 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.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search