reactjsfrontendreact-hook-form

React Hook Form validation not preventing form submission with useController custom hook


I'm using React Hook Form in my project to handle form validation. I have a custom hook useFormField that integrates with useController from React Hook Form. However, I'm encountering an issue where the form submission is not being prevented even when validation rules are not met.

Here is the relevant code:

Custom Hook (useFormField):

import { useController, useFormContext, RegisterOptions, FieldValues, FieldPath, UseControllerProps } from 'react-hook-form';

type UseFormFieldProps<TFieldValues extends FieldValues> = {
  name: FieldPath<TFieldValues>;
  defaultValue?: any;
  rules?: Omit<RegisterOptions<TFieldValues, FieldPath<TFieldValues>>, 'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'>;
};

export function useFormField<TFieldValues extends FieldValues>({ name, defaultValue, rules }: UseFormFieldProps<TFieldValues>) {
  const { control } = useFormContext<TFieldValues>();

  const { field, fieldState: { error, isTouched, isDirty }, formState: { isSubmitting } } = useController({
    name,
    control,
    defaultValue,
    rules,
  } as UseControllerProps<TFieldValues>);

  return {
    ...field,
    error,
    isTouched,
    isDirty,
    isSubmitting,
  };
}

Test Component:

import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { FormProvider, useForm } from 'react-hook-form';
import { useFormField } from '@/hooks/useFormField';

function TestComponent({ name, defaultValue, rules, onSubmit = (d) => {} }: { name: string, defaultValue?: any, rules?: any, onSubmit?: (data: any) => void }) {
  const methods = useForm({ mode: 'onChange' });

  const { value, onChange, onBlur, error, isTouched, isDirty, isSubmitting } = useFormField({ name, defaultValue, rules });

  return (
    <form onSubmit={methods.handleSubmit(onSubmit)} data-testid="form">
      <input type="text" value={value || ''} onChange={onChange} onBlur={onBlur} data-testid="input" />
      {error && <span data-testid="error">{error.message}</span>}
      <span data-testid="touched">{isTouched ? 'touched' : 'untouched'}</span>
      <span data-testid="dirty">{isDirty ? 'dirty' : 'clean'}</span>
      <span data-testid="submitting">{isSubmitting ? 'submitting' : 'not submitting'}</span>
      <button type="submit" data-testid="submit">Submit</button>
    </form>
  );
}

function Wrapper({ children }: { children: React.ReactNode }) {
  const methods = useForm({ mode: 'onChange' });
  return <FormProvider {...methods}>{children}</FormProvider>;
}

Test Case:

it('should apply validation rules on submit', async () => {
  const onSubmitMock = jest.fn();
  render(
    <Wrapper>
      <TestComponent name="test" rules={{ required: 'This field is required' }} onSubmit={onSubmitMock} />
    </Wrapper>
  );

  const input = screen.getByTestId('input');
  const submitButton = screen.getByTestId('submit');

  // Submit with empty field
  fireEvent.click(submitButton);

  await waitFor(() => {
    expect(onSubmitMock).not.toHaveBeenCalled();
    const errorElement = screen.queryByTestId('error');
    expect(errorElement).not.toBeNull();
    expect(errorElement).toHaveTextContent('This field is required');
  });

  // Fill in the field and submit again
  fireEvent.change(input, { target: { value: 'valid input' } });
  fireEvent.click(submitButton);

  await waitFor(() => {
    expect(onSubmitMock).toHaveBeenCalledWith(expect.objectContaining({ test: 'valid input' }));
    expect(screen.queryByTestId('error')).toBeNull();
  });
});

Despite the validation rules being defined, the onSubmit function is still being called when the form is submitted with an empty field. The expected behavior is that the form submission should be prevented and the validation error should be displayed.

What could be causing the form submission to proceed even when validation fails? How can I ensure that the form submission is properly prevented when validation rules are not met?


Solution

  • In the TestComponent, you are calling useForm again, which creates two separate form definitions. The control value in your custom hook is coming from the context, while the other values are coming from the newly defined form within TestComponent. You should be using the context from the Wrapper component instead.

    function TestComponent({
      name,
      defaultValue,
      rules,
      onSubmit = (d) => {},
    }: {
      name: string;
      defaultValue?: any;
      rules?: any;
      onSubmit?: (data: any) => void;
    }) {
      const methods = useFormContext<FieldValues>(); // this line
    
      const { value, onChange, onBlur, error, isTouched, isDirty, isSubmitting } =
        useFormField({ name, defaultValue, rules });
    
      return (
        <form onSubmit={methods.handleSubmit(onSubmit)} data-testid="form">
          <input
            type="text"
            value={value || ''}
            onChange={onChange}
            onBlur={onBlur}
            data-testid="input"
          />
          {error && <div data-testid="error">{error.message}</div>}
          <div data-testid="touched">{isTouched ? 'touched' : 'untouched'}</div>
          <div data-testid="dirty">{isDirty ? 'dirty' : 'clean'}</div>
          <div data-testid="submitting">
            {isSubmitting ? 'submitting' : 'not submitting'}
          </div>
          <button type="submit" data-testid="submit">
            Submit
          </button>
        </form>
      );
    }
    
    function Wrapper({ children }: { children: React.ReactNode }) {
      const methods = useForm({ mode: 'onChange' });
      return <FormProvider {...methods}>{children}</FormProvider>;
    }
    

    Make sure to use useFormContext in TestComponent to access the correct form context, ensuring that the control and other state values are consistent across your components.