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:
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:
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).
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';functionApp(){const{register,getValues,formState:{errors,submitCount}} = useForm({defaultValues:{email:'',password:''}});consthandleSubmit = (e)=>{e.preventDefault();constvalues = getValues();// Simulate API callalert('Form submitted ' + submitCount + ' times with: ' + JSON.stringify(values));};return(<formonSubmit={handleSubmit}><div><label>Email:</label><input{...register('email',{required:'Email is required'})}/>{errors.email && <divstyle={{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 && <divstyle={{color:'red'}}>{errors.password.message}</div>}</div><buttontype="submit">Submit</button></form>);}exportdefaultApp;
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';functionApp(){const{register,handleSubmit,formState:{errors,submitCount}} = useForm({defaultValues:{email:'',password:''}});constonSubmit = (data)=>{// Simulate API callalert('Form submitted ' + submitCount + ' times with: ' + JSON.stringify(data));};return(<formonSubmit={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><buttontype="submit">Submit</button></form>);}exportdefaultApp;
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*aszfrom'zod';constschema = 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'),});functionApp(){const{watch,setValue,trigger,formState:{errors}} = useForm({defaultValues:{email:'',password:''},resolver:zodResolver(schema),});constemail = watch('email');constpassword = watch('password');return(<formonSubmit={e=>e.preventDefault()}><div><label>Email:</label><inputvalue={email}onChange={(e)=>{setValue('email',e.target.value);trigger('email');}}/>{errors.email && <divstyle={{color:'red'}}>{errors.email.message}</div>}</div><div><label>Password:</label><inputvalue={password}onChange={(e)=>{setValue('password',e.target.value);trigger('password');}}type="password"/>{errors.password && <divstyle={{color:'red'}}>{errors.password.message}</div>}</div><buttontype="submit">Submit</button></form>);}exportdefaultApp;
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*aszfrom'zod';constschema = 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'),});functionApp(){const{register,formState:{errors}} = useForm({defaultValues:{email:'',password:''},resolver:zodResolver(schema),mode:'onChange',reValidateMode:'onChange'});return(<formonSubmit={e=>e.preventDefault()}><div><label>Email:</label><input{...register('email',{required:'Email is required'})}/>{errors.email && <divstyle={{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 && <divstyle={{color:'red'}}>{errors.password.message}</div>}</div><buttontype="submit">Submit</button></form>);}exportdefaultApp;
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.