I spent several days debugging React Hook Form, understanding why some components rerender while others don't, learning about subscriptions, Proxies, Controllers, FormProvider, useFormState, and the internals that make RHF one of the fastest form libraries in the React ecosystem.
This article contains everything I wish someone had explained when I started.
If you understand all of these concepts, you'll not only become productive with RHF but also gain a deeper understanding of React rendering, subscriptions, and state management.
Let's begin.
Why React Hook Form Exists
Most React developers start with this approach:
const [name, setName] = useState("");
<input
value={name}
onChange={(e) => setName(e.target.value)}
/>
This works fine for a few fields.
But imagine building:
- Login Form
- Signup Form
- Admin Dashboard
- Course Creation Page
- E-commerce Product Form
- Job Portal
- CMS
- Dynamic Forms
Suddenly you have:
const [name,setName]
const [email,setEmail]
const [password,setPassword]
const [errors,setErrors]
const [isDirty,setDirty]
const [touched,setTouched]
The code becomes messy.
Every keystroke rerenders components.
Validation logic becomes difficult.
Performance suffers.
React Hook Form solves these problems.
Why React Hook Form is Fast
Most libraries use:
useState()
RHF uses:
Uncontrolled Components
Instead of storing every keystroke inside React State,
RHF stores values inside refs.
This means:
Typing:
a
ab
abc
abcd
does NOT rerender the entire component tree.
This is the secret sauce.
useForm()
The most important hook.
const methods = useForm();
It returns:
register
handleSubmit
watch
control
formState
reset
setValue
getValues
trigger
setError
clearErrors
resetField
setFocus
register()
Simplest way to connect an input.
<input
{...register("name")}
/>
RHF internally adds:
value
onChange
onBlur
ref
Automatically.
Validation
Simple validation
register(
"name",
{
required:true
}
)
Advanced
register(
"name",
{
required:"Name required",
minLength:{
value:3,
message:"Minimum 3"
}
}
)
Zod Integration
Install
npm i zod
npm i @hookform/resolvers
Schema
const Schema=z.object({
name:
z.string()
.min(
1,
"Required"
)
});
Hook
useForm({
resolver:
zodResolver(
Schema
)
})
handleSubmit()
const submit=(data)=>{
console.log(data);
};
<form
onSubmit={
handleSubmit(
submit
)
}
>
Error callback
const onError=(errors)=>{
console.log(errors);
};
handleSubmit(
submit,
onError
);
watch()
Subscribe to field values.
watch("email");
Example
const title=
watch(
"courseName"
);
When title changes,
Component rerenders.
useWatch()
Optimized version.
const title=useWatch({
control,
name:"courseName"
});
Only rerenders for this field.
Best for large applications.
getValues()
Read values.
No rerender.
getValues();
Single
getValues(
"email"
);
setValue()
Programmatically update.
setValue(
"name",
"Abhishek"
);
Options
setValue(
"name",
"John",
{
shouldValidate:true,
shouldDirty:true,
shouldTouch:true
}
);
reset()
reset();
Default
reset({
name:"John"
});
trigger()
Validate manually.
await trigger();
Specific
await trigger(
"email"
);
setError()
Custom validation.
setError(
"email",
{
message:"Email exists"
}
);
clearErrors()
clearErrors();
Specific
clearErrors(
"email"
);
Controller()
For custom components.
Bad
<Input
value={watch()}
onChange={()=>setValue()}
/>
Good
<Controller
name="title"
control={control}
render(
{
field,
fieldState
}
)=>(
<Input
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
/>
)
}
/>
fieldState
Inside Controller
fieldState.error
fieldState.invalid
fieldState.isTouched
Automatic subscription.
useFieldArray()
Dynamic forms.
Perfect for:
Skills
Projects
Education
Requirements
Admission Criteria
Example
const{
fields,
append,
remove
}=
useFieldArray({
control,
name:"skills"
});
Add
append({
value:""
});
Delete
remove(index);
FormProvider
Best architecture.
Wrapper
<FormProvider
{...methods}
>
<App/>
</FormProvider>
Consume
const methods=
useFormContext();
The Biggest Confusion: formState
Everyone writes:
const{
formState:{
errors
}
}=methods;
Sometimes
Errors update.
Sometimes
Component doesn't rerender.
Why?
formState is NOT Normal State
It's a
Proxy.
Something similar to:
new Proxy(
state,
{
get(){
subscribe();
}
}
)
RHF only rerenders subscribers.
Why My Component Didn't Rerender?
I had:
const methods=
useCourseFormContext();
console.log(
methods.formState.errors
);
Validation happened.
Errors existed.
But component never rerendered.
Solution
Subscribe.
const{
errors
}=
useFormState({
control
});
Now
Errors change
β
Component rerenders
Instantly.
useFormState()
Perfect for:
errors
isDirty
isValid
dirtyFields
touchedFields
submitCount
Example
const{
errors,
isDirty,
isValid
}=
useFormState({
control
});
Validation Modes
onSubmit
mode:"onSubmit"
Only submit.
onChange
mode:"onChange"
Validate every key.
onBlur
mode:"onBlur"
Validate after blur.
all
mode:"all"
Everything.
Performance Tips
Use
β register
β Controller
β fieldState
β useWatch
β useFormState
β useFieldArray
β FormProvider
Avoid
β watch everywhere
β setValue everywhere
β Multiple useForm()
β Manual states
β Copying RHF data into useState
Interview Questions
Why is RHF fast?
Because it uses uncontrolled components, refs, subscriptions, and Proxy-based formState instead of storing every field in React state.
Difference between watch and useWatch?
watch rerenders the parent component.
useWatch subscribes only to selected fields.
Difference between Controller and register?
register works for native inputs.
Controller is designed for custom controlled components.
Why useFormState?
To subscribe components to errors, dirty state, touched fields, and validity changes.
Why FormProvider?
To avoid prop drilling and share the same form instance across deeply nested components.
Final Thoughts
React Hook Form isn't just another form library.
It's a carefully designed subscription-based state management system optimized specifically for forms.
Top comments (0)