React Hook Form 01 ๐ Why React Hook Form โ Uncontrolled inputs & minimal re-renders
React Hook Form exists because the controlled-input model, which is the default in React, does not scale. A controlled input stores its value in React state, and every keystroke triggers a state update, which triggers a re-render of the component that holds the state, and often of the entire form. For a login form with two fields, the re-render is invisible. For a dynamic form with fifty fields, a nested field array, and a validation schema, the re-render on every keystroke is a visible latency that the user feels as they type. React Hook Form was built to remove that cost by using the browser’s native input behaviorโthe uncontrolled inputโand subscribing to the form state only where the UI actually reads it.
The library’s core idea is that the DOM already stores the input values, and React does not need to. The register function connects a native input to the form, and the input keeps its own value. The library reads the values when the form is submitted, or when a specific component subscribes to a specific field. The result is that typing does not re-render the form, the validation runs on the values that the DOM holds, and the formState objectโthe errors, the dirty fields, the submission stateโre-renders only the components that read those specific properties . The official FAQ states the difference plainly: “React Hook Form uses refs and uncontrolled native inputs by default, while still supporting controlled components. This can reduce the amount of state and rendering work needed as a form grows” .
Key point: React Hook Form uses uncontrolled native inputs by default, so typing does not trigger a React re-render. The values live in the DOM, and the library reads them at submission or through a scoped subscription. The Controller component is the escape hatch for the controlled componentsโthe third-party selects, the date pickersโthat cannot expose a native ref. The trade-off is a different mental model: the values are not in React state, and the live-derived UI requires an explicit subscription.
Why React Hook Form exists
The re-render problem. A controlled input calls setState on every keystroke, and React re-renders the component tree that depends on that state. The re-render is cheap for a single field, but it compounds with the number of the fields, the complexity of the component tree, and the validation logic that runs during the render. The React Hook Form FAQ describes the uncontrolled model as the mechanism that “can reduce the amount of state and rendering work needed as a form grows” . The reduction is the primary reason the library exists.
The state-synchronization problem. A controlled form keeps the values in React state, and the DOM inputs are the reflection of that state. The two must be kept in sync, and the synchronization is the source of the bugs: the input that ignores the first keystroke because the value prop is set without the onChange, the field that switches from uncontrolled to controlled because the initial value is undefined, the form that resets the state but not the DOM. The uncontrolled model removes the synchronization by removing the React state. The DOM is the source of truth, and the library reads it .
The validation-timing problem. A controlled form validates on every keystroke because the state changes on every keystroke. The validation on every keystroke is expensive, and it produces the error messages while the user is still typing the first character. React Hook Form separates the validation timing from the value storage: the mode option controls when the validation runs, and the default is onSubmit. The mode: "onTouched" is the recommended setting for the forms that want the errors after the user leaves a field, and then update on every change . The separation is the mechanism that lets the validation be as aggressive or as relaxed as the UX requires.
The trade-off. The uncontrolled model is faster, but it is a different mental model. The values are not in React state, and a developer who is used to reading values.email in the render will not find it. The live-derived UIโthe preview that updates as the user types, the section that appears when a checkbox is checkedโrequires an explicit subscription with watch or useWatch, and the subscription re-introduces the controlled cost for the components that use it . The performance is the benefit, and the explicit subscription is the cost.
The bundle-size problem. The library ships at roughly 12 KB, with no dependencies, while Formik ships at roughly 44 KB with its dependencies . The size difference is not the primary reason to choose React Hook Form, but it is the secondary reason: a form library is loaded on every page that has a form, and the smaller bundle is the faster page. The zero-dependency design is the mechanism that keeps the size small, and the size is the mechanism that keeps the load time low.
a. The uncontrolled input and the register function
The register function is the connection between the native input and the form. It returns the name, the onChange, the onBlur, and the ref props, and the input spreads them. The ref is the mechanism that lets the library read the DOM value, and the onChange and the onBlur are the mechanisms that let the library run the validation and update the formState .
// The uncontrolled input with register
import { useForm } from "react-hook-form";
function LoginForm() {
const { register, handleSubmit } = useForm({
defaultValues: { email: "", password: "" },
mode: "onTouched",
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
{/* The input spreads the register props. */}
{/* The value is stored in the DOM, not in React state. */}
<input {...register("email")} type="email" />
<input {...register("password")} type="password" />
<button type="submit">Log in</button>
</form>
);
}
The defaultValues option is the mechanism that sets the initial value. The value is read once on the first render, and the form compares the current values against it for the isDirty and the dirtyFields state . The mode: "onTouched" option is the mechanism that runs the validation when the user leaves the field, and then on every change after the first error .
The controlled component is the exception. A third-party select, a date picker, or a masked input that exposes a value and an onChange cannot use the register because it does not expose a native ref. The Controller component is the bridge, and the useController hook is the hook form of the same bridge .
// The controlled component with Controller
import { useForm, Controller } from "react-hook-form";
import DatePicker from "react-datepicker";
function BookingForm() {
const { control, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
{/* The Controller connects the controlled component. */}
{/* The field is stored in the form, but the component owns the value. */}
<Controller
name="date"
control={control}
defaultValue={new Date()}
render={({ field }) => (
<DatePicker selected={field.value} onChange={field.onChange} />
)}
/>
<button type="submit">Book</button>
</form>
);
}
The Controller is the deliberate choice for the components that need it. The official documentation recommends the register as the default and the Controller as the exception, because the register is the fast path and the Controller re-introduces the controlled cost for the field it wraps .
b. The formState subscription and the minimal re-render
The formState object is the state that the form exposes: the errors, the isDirty, the isValid, the isSubmitting, the isSubmitSuccessful, the touchedFields, and the dirtyFields. The object is a proxy, and the library tracks which properties the component reads during the render. The component re-renders only when one of the properties it reads changes . A component that reads formState.errors.email does not re-render when the isDirty changes, because it did not read the isDirty.
// The narrow formState subscription
function EmailField() {
const {
register,
formState: { errors },
} = useForm();
// The component reads only errors.email.
// It re-renders only when errors.email changes.
return (
<div>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
</div>
);
}
The narrow subscription is the mechanism that keeps the re-renders proportional to the changes. A form with fifty fields, each in its own component, re-renders only the field that has the error, not the whole form. The formState is the state, and the proxy is the mechanism that tracks the reads .
The watch and the useWatch are the mechanisms for the live values. The watch subscribes to the value changes and re-renders the component that calls it, and the useWatch is the hook that scopes the subscription to the specific field. The getValues is the mechanism that reads the values without the subscription, and it is the one for the event handlers and the effects, not for the render .
// The scoped watch
import { useForm, useWatch } from "react-hook-form";
function ShippingForm() {
const { register, control } = useForm();
// The useWatch scopes the subscription to the country field.
const country = useWatch({ control, name: "country" });
return (
<form>
<input {...register("country")} />
{/* Only this section re-renders when the country changes. */}
{country === "US" && <input {...register("state")} />}
</form>
);
}
The useWatch is the mechanism that keeps the live-derived UI fast. The component that calls it re-renders on the field change, and the rest of the form does not. The official FAQ states the rule: “Read values at submit, not in render. Avoid watch('field') in the render path unless you specifically need to react to a value live… and when you do, scope it to the smallest component so only that subtree re-renders” .
Complete Example Session
// ============================================
// PART 1: THE BASIC FORM
// ============================================
import { useForm } from "react-hook-form";
type FormValues = {
email: string;
password: string;
};
function SignUpForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
defaultValues: { email: "", password: "" },
mode: "onTouched",
});
const onSubmit = async (data: FormValues) => {
// The data is read from the DOM at submission.
await fetch("/api/sign-up", {
method: "POST",
body: JSON.stringify(data),
});
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} type="email" />
<input {...register("password")} type="password" />
<button type="submit" disabled={isSubmitting}>
Sign up
</button>
</form>
);
}
// ============================================
// PART 2: THE REGISTER OPTIONS
// ============================================
// The register accepts the validation rules.
<input
{...register("email", {
required: "The email is required.",
pattern: {
value: /^\S+@\S+$/i,
message: "The email is invalid.",
},
})}
type="email"
/>
// ============================================
// PART 3: THE CONTROLLED COMPONENT
// ============================================
import { useForm, Controller } from "react-hook-form";
import Select from "react-select";
function CountryForm() {
const { control, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<Controller
name="country"
control={control}
render={({ field }) => (
<Select
options={[{ value: "us", label: "United States" }]}
onChange={(option) => field.onChange(option?.value)}
value={{ value: field.value, label: field.value }}
/>
)}
/>
</form>
);
}
// ============================================
// PART 4: THE FORMSTATE SUBSCRIPTION
// ============================================
function ErrorSummary() {
const {
formState: { errors, isDirty },
} = useForm();
// The component reads errors and isDirty.
// It re-renders when either changes.
return (
<div>
{isDirty && <p>The form has unsaved changes.</p>}
{errors.email && <p>{errors.email.message}</p>}
</div>
);
}
// ============================================
// PART 5: THE SCOPED WATCH
// ============================================
import { useForm, useWatch } from "react-hook-form";
function ConditionalForm() {
const { register, control } = useForm();
const hasCompany = useWatch({ control, name: "hasCompany" });
return (
<form>
<input type="checkbox" {...register("hasCompany")} />
{hasCompany && <input {...register("companyName")} />}
</form>
);
}
// ============================================
// PART 6: THE FORM ARRAY
// ============================================
import { useForm, useFieldArray } from "react-hook-form";
function ItemsForm() {
const { register, control, handleSubmit } = useForm({
defaultValues: { items: [{ name: "" }] },
});
const { fields, append, remove } = useFieldArray({
control,
name: "items",
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
<button type="button" onClick={() => remove(index)}>
Remove
</button>
</div>
))}
<button type="button" onClick={() => append({ name: "" })}>
Add
</button>
</form>
);
}
// ============================================
// PART 7: THE RESET
// ============================================
function ResetForm() {
const { register, reset, handleSubmit } = useForm({
defaultValues: { name: "" },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("name")} />
<button type="button" onClick={() => reset()}>
Reset
</button>
</form>
);
}
// ============================================
// PART 8: THE SCHEMA RESOLVER
// ============================================
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
function SchemaForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input {...register("password")} type="password" />
{errors.password && <p>{errors.password.message}</p>}
</form>
);
}
// ============================================
// PART 9: THE DEFAULT VALUES FROM THE API
// ============================================
// The values prop is the mechanism for the async data.
function EditForm({ user }) {
const { register, handleSubmit } = useForm({
// The values prop updates the form when the data changes.
values: user,
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("name")} />
</form>
);
}
// ============================================
// PART 10: THE CHECKLIST
// ============================================
// [ ] The register is used for the native inputs.
// [ ] The Controller is used for the controlled components.
// [ ] The mode is set to "onTouched" for the late validation.
// [ ] The formState is destructured narrowly.
// [ ] The useWatch is used for the live-derived UI.
// [ ] The defaultValues is set for every field.
// [ ] The values prop is used for the async data.
The ten parts covered the basic form, the register options, the controlled component, the formState subscription, the scoped watch, the form array, the reset, the schema resolver, the async data, and the checklist. The through-line is that the uncontrolled inputs keep the typing off the React render path, and the scoped subscriptions keep the re-renders proportional to the changes.
Quick Reference
The core API
| API | Purpose |
|---|---|
useForm | The form hook, the entry point |
register | Connects the native input |
handleSubmit | Wraps the submit handler |
formState | The errors, the dirty state, the submission state |
Controller | Connects the controlled component |
useWatch | The scoped live value subscription |
useFieldArray | The dynamic field list |
The validation modes
| Mode | When the validation runs |
|---|---|
onSubmit | The default, on submission |
onTouched | On blur, then on change |
onChange | On every change |
all | On blur and on change |
The state subscription
| Mechanism | Re-renders |
|---|---|
register | No re-render on typing |
formState.errors | Only when the error changes |
watch | On every value change |
useWatch | On the scoped value change |
The library comparison
| Library | State model | Bundle size |
|---|---|---|
| React Hook Form | Uncontrolled | ~12 KB |
| Formik | Controlled | ~44 KB |
| React Final Form | Subscription | โ |
Best Practices
โ Do This:
// Use register for the native inputs
<input {...register("email")} /> // โ
// Use mode: "onTouched" for the late validation
useForm({ mode: "onTouched" }); // โ
// Use useWatch for the live-derived UI
const value = useWatch({ control, name: "country" }); // โ
// Set the defaultValues for every field
useForm({ defaultValues: { email: "" } }); // โ
โ Don’t Do This:
// Wrap every field in Controller
<Controller name="email" control={control} render={...} /> // โ
// Mirror the RHF values into useState
const [email, setEmail] = useState(); watch("email"); // โ
// Read the formState in the event handler
const { formState: { isValid } } = useForm(); onClick={() => isValid} // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The field is empty after the API loads | The defaultValues is read once | Use the values prop |
| The controlled input ignores the first keystroke | The value prop is set without the onChange | Use the register or the Controller |
The isValid is always false | The formState is read only in the handler | Read it during the render |
| The form re-renders on every keystroke | The watch is used in the render | Use the useWatch scoped to the component |
Real-World Examples
1. The basic register
<input {...register("email")} />
2. The register with the validation
<input {...register("email", { required: "Required" })} />
3. The Controller
<Controller name="date" control={control} render={({ field }) => <DatePicker {...field} />} />
4. The formState errors
const { formState: { errors } } = useForm();
5. The useWatch
const country = useWatch({ control, name: "country" });
6. The useFieldArray
const { fields, append } = useFieldArray({ control, name: "items" });
7. The reset
reset({ email: "" });
8. The zodResolver
useForm({ resolver: zodResolver(schema) });
9. The values prop
useForm({ values: user });
10. The mode
useForm({ mode: "onTouched" });
Visual
The Uncontrolled vs Controlled
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE UNCONTROLLED INPUT โ
โ โ
โ The user types โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The DOM updates โ No React render โ
โ โ the input value โ โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The library reads โ At submission or on the โ
โ โ the value โ scoped subscription โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ The controlled input would call setState on every keystrokeโ
โ and re-render the form. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The formState Subscription
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE NARROW SUBSCRIPTION โ
โ โ
โ formState โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ errors โ Read by the component โ
โ โ isDirty โ Not read, no re-render โ
โ โ isSubmitting โ Not read, no re-render โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The component re- โ Only when errors changes โ
โ โ renders โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Library Comparison
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ REACT HOOK FORM VS FORMIK โ
โ โ
โ React Hook Form Formik โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Uncontrolled โ โ Controlled โ โ
โ โ No re-render on โ โ Re-render on every โ โ
โ โ typing โ โ keystroke โ โ
โ โ ~12 KB โ โ ~44 KB โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ The uncontrolled model is the performance advantage. โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Core model | Uncontrolled inputs by default |
register | Connects the native input, no re-render on typing |
Controller | The escape hatch for the controlled components |
formState | The proxy that tracks the reads |
useWatch | The scoped live value subscription |
mode | The validation timing, onTouched recommended |
| Bundle size | ~12 KB, no dependencies |
| Comparison | Formik is controlled and ~44 KB |
Key takeaways:
- The uncontrolled inputs are the performance mechanism. The
registerconnects the native input, and the DOM stores the value. Typing does not trigger a React re-render, which is the reason the large forms stay responsive . - The
Controlleris the exception, not the default. The controlled componentsโthe third-party selects, the date pickers, the masked inputsโneed theController, and wrapping every field in it throws away the performance advantage . - The
formStateis a proxy that tracks the reads. A component re-renders only when the properties it reads change. The narrow destructuring is the mechanism that keeps the re-renders proportional to the changes . - The
useWatchis the scoped subscription for the live-derived UI. Thewatchin the render path re-renders the whole form, and theuseWatchscopes the re-render to the component that reads the value . - The
modeseparates the validation timing from the value storage. The default isonSubmit, and theonTouchedis the recommended setting for the errors after the user leaves a field .
Remember: React Hook Form exists because the controlled input does not scale. The register connects the native input, and the DOM stores the value. The formState is a proxy that re-renders only the components that read the changed properties. The Controller is the escape hatch for the controlled components. The useWatch is the scoped subscription for the live-derived UI. The trade-off is the different mental model: the values are not in React state, and the live-derived UI requires the explicit subscription.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!