Common Anti-Patterns in React Hook Form - Phelipe Teles

Common Anti-Patterns in React Hook Form

2 min.
Source code

Throughout my career, a staple React library in every team was React Hook Form, for good reasons — it’s pretty easy to use. Unfortunately, it’s also easy to misuse. When working with it, I often saw code that got the job done but was inefficient or non-idiomatic. Let’s talk about them.

Using watch and setValue instead of Controller

This anti-pattern involves using watch and setValue to manually control an input, which seems natural but causes performance issues. Here’s how it looks:

import { useForm } from 'react-hook-form';

let rerenderCount = 0

function App() {
  rerenderCount++
  const { watch, setValue } = useForm({ defaultValues: { name: '' } });
  const name = watch('name');

  return (
    <>
      <label>
        Name: <input
          value={name}
          onChange={(e) => {
            setValue('name', e.target.value);
          }}
        />
      </label>
      <br />Re-renders: {rerenderCount}
    </>
  );
}

export default App;

This code works, but it’s not optimal since the whole component will re-render whenever the input changes (try it above!).

This is unlikely to become a performance issue (unless heavily abused in a huge form) but it’s easier to just use a Controller or an uncontrolled component like this:

import { useForm, Controller } from 'react-hook-form';

let rerenderCount = 0

function App() {
  rerenderCount++
  const { control } = useForm({ defaultValues: { name: '' } });

  return (
    <>
      <Controller
        name="name"
        control={control}
        render={({ field }) => (
          <label>
            Name: <input {...field} />
          </label>
        )}
      />
      <br />Re-renders: {rerenderCount}
    </>
  );
}

export default App;

In the example above we can see the component never re-renders even though the input state changes. This is because now Controller owns the input state.

Additionally, there are also deeper integration issues with the library that often go unnoticed at first glance.

The example below demonstrates two features that don’t work properly with this anti-pattern: a button to focus the input using setFocus, and a button to log formState.dirtyFields (which always appears empty).

import { useForm } from 'react-hook-form';

function App() {
  const { watch, setValue, formState, setFocus } = useForm({
    defaultValues: { name: '' }
  });
  const name = watch('name');

  return (
    <div>
      <label>
        Name: <input
          value={name}
          onChange={(e) => setValue('name', e.target.value)}
        />
      </label>
      <br />
      <button onClick={() => console.log('Dirty fields:', formState.dirtyFields)}>
        Log Dirty Fields
      </button>
      <button onClick={() => setFocus('name')}>
        Set Focus
      </button>
    </div>
  );
}

export default App;
Show console (0)
No logs yet

Both of these buttons work as expected when we use Controller:

import { useForm, Controller } from 'react-hook-form';

function App() {
  const { control, formState, setFocus } = useForm({
    defaultValues: { name: '' }
  });

  return (
    <div>
      <Controller
        name="name"
        control={control}
        render={({ field }) => (
          <label>
            Name: <input {...field} />
          </label>
        )}
      />
      <br />
      <button onClick={() => console.log('Dirty fields:', formState.dirtyFields)}>
        Log Dirty Fields
      </button>
      <button onClick={() => setFocus('name')}>
        Set Focus
      </button>
    </div>
  );
}

export default App;
Show console (0)
No logs yet

This is because React Hook Form Controller and register APIs pass more than just a value and onChange prop to each form field: ref and onBlur are important props to imperatively focus and track dirty fields state.

Using getValues on submit event handlers

Another common anti-pattern is manually reading form values in the submit handler instead of using React Hook Form’s handleSubmit.

This pattern gets the job done since there is nothing wrong with using getValues to get the form values. But when submitting a form, there’s no reason not to use handleSubmit.

In the example below we can see the following issues:

  • The submit handler runs even when form values are invalid.
  • Error messages are not displayed on form submission.
  • formState.submitCount is always 0.
import { useForm } from 'react-hook-form';

function App() {
  const { register, getValues, formState: { errors, submitCount } } = useForm({
    defaultValues: { email: '', password: '' }
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    const values = getValues();
    // Simulate API call
    alert('Form submitted ' + submitCount + ' times with: ' + JSON.stringify(values));
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>Email:</label>
        <input
          {...register('email', { required: 'Email is required' })}
        />
        {errors.email && <div style={{ color: 'red' }}>{errors.email.message}</div>}
      </div>
      <div>
        <label>Password:</label>
        <input
          {...register('password', { required: 'Password is required', minLength: { value: 6, message: 'Password must be at least 6 characters' } })}
          type="password"
        />
        {errors.password && <div style={{ color: 'red' }}>{errors.password.message}</div>}
      </div>
      <button type="submit">Submit</button>
    </form>
  );
}

export default App;

The correct way is to use handleSubmit, which accepts two callbacks to handle successful and invalid form submissions, in this order.

import { useForm } from 'react-hook-form';

function App() {
  const { register, handleSubmit, formState: { errors, submitCount } } = useForm({
    defaultValues: { email: '', password: '' }
  });

  const onSubmit = (data) => {
    // Simulate API call
    alert('Form submitted ' + submitCount + ' times with: ' + JSON.stringify(data));
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <div>
        <label>Email:</label>
        <input
          {...register('email', { required: 'Email is required' })}
        />
        {errors.email && <div>{errors.email.message}</div>}
      </div>
      <div>
        <label>Password:</label>
        <input
          {...register('password', { required: 'Password is required', minLength: { value: 6, message: 'Password must be at least 6 characters' } })}
          type="password"
        />
        {errors.password && <div>{errors.password.message}</div>}
      </div>
      <button type="submit">Submit</button>
    </form>
  );
}

export default App;

Now the API call only happens if form values are valid and the error messages are correctly displayed if they’re invalid.

Using trigger instead of mode or reValidateMode

Another anti-pattern is manually calling trigger on every input change to validate fields whenever the value changes.

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';

const schema = z.object({
  email: z.string().min(1, 'Email is required').email('Invalid email'),
  password: z.string().min(6, 'Password must be at least 6 characters'),
});

function App() {
  const { watch, setValue, trigger, formState: { errors } } = useForm({
    defaultValues: { email: '', password: '' },
    resolver: zodResolver(schema),
  });
  const email = watch('email');
  const password = watch('password');

  return (
    <form onSubmit={e => e.preventDefault()}>
      <div>
        <label>Email:</label>
        <input
          value={email}
          onChange={(e) => {
            setValue('email', e.target.value);
            trigger('email');
          }}
        />
        {errors.email && <div style={{ color: 'red' }}>{errors.email.message}</div>}
      </div>
      <div>
        <label>Password:</label>
        <input
          value={password}
          onChange={(e) => {
            setValue('password', e.target.value);
            trigger('password');
          }}
          type="password"
        />
        {errors.password && <div style={{ color: 'red' }}>{errors.password.message}</div>}
      </div>
      <button type="submit">Submit</button>
    </form>
  );
}

export default App;

This anti-pattern often appears alongside the others mentioned, as developers who manually control inputs may naturally reach for manual validation triggering as well. React Hook Form needs to manage the onChange, onBlur and onSubmit event handlers to validate fields, which do not happen if we don’t use Controller, register and handleSubmit.

A simpler approach that achieves the same result without modifying every event handler is by changing the mode and reValidateMode options in useForm:

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';

const schema = z.object({
  email: z.string().min(1, 'Email is required').email('Invalid email'),
  password: z.string().min(6, 'Password must be at least 6 characters'),
});

function App() {
  const { register, formState: { errors } } = useForm({
    defaultValues: { email: '', password: '' },
    resolver: zodResolver(schema),
    mode: 'onChange',
    reValidateMode: 'onChange'
  });

  return (
    <form onSubmit={e => e.preventDefault()}>
      <div>
        <label>Email:</label>
        <input
          {...register('email', { required: 'Email is required' })}
        />
        {errors.email && <div style={{ color: 'red' }}>{errors.email.message}</div>}
      </div>
      <div>
        <label>Password:</label>
        <input
          {...register('password', { required: 'Password is required', minLength: { value: 6, message: 'Password must be at least 6 characters' } })}
          type="password"
        />
        {errors.password && <div style={{ color: 'red' }}>{errors.password.message}</div>}
      </div>
      <button type="submit">Submit</button>
    </form>
  );
}

export default App;

A use case for the trigger API is to trigger validation in a different field when one field value changes — think re-validating a phone number when the selected country changes.