Overview
React Hook Form is a form state and validation library for React that is built around registering fields, reading values at submit time, and minimizing rerenders. Its default model works especially well with uncontrolled native inputs, where the browser owns the current input value and React Hook Form tracks the field through refs and event handlers.
This matters because production forms often become performance bottlenecks. A large controlled form can rerender many components on every keystroke if state is lifted too high or subscribed too broadly. React Hook Form helps avoid that by letting fields register with the form control and by exposing targeted APIs for field state, form state, validation, reset, and submission.
React Hook Form is used for login forms, profile forms, checkout flows, admin CRUD screens, search filters, multi-step flows, and large enterprise forms. It can handle simple native inputs with register, controlled third-party components with Controller, and complex validation through built-in rules or schema resolvers.
For interviews, this topic is important because it tests whether a candidate understands form ownership, controlled versus uncontrolled inputs, validation timing, form state subscriptions, accessibility, and how to integrate component libraries without fighting React.
Core Concepts
React Hook Form's Mental Model
React Hook Form starts with useForm.
import { useForm } from "react-hook-form";
type LoginFormValues = {
email: string;
password: string;
};
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormValues>({
defaultValues: {
email: "",
password: "",
},
});
async function onSubmit(values: LoginFormValues) {
await login(values);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label>
Email
<input
type="email"
{...register("email", { required: "Email is required" })}
/>
</label>
{errors.email ? <p role="alert">{errors.email.message}</p> : null}
<label>
Password
<input
type="password"
{...register("password", { required: "Password is required" })}
/>
</label>
{errors.password ? <p role="alert">{errors.password.message}</p> : null}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Signing in..." : "Sign in"}
</button>
</form>
);
}
The main pieces are:
register: connects an input to the form.handleSubmit: validates and calls success or error callbacks.formState: exposes form-level state such as errors, dirty state, touched fields, submit state, and validation state.defaultValues: defines the baseline values used for dirty tracking and reset.reset,setValue,getValues,watch, andtrigger: imperative helpers for common form workflows.
Uncontrolled Inputs
An uncontrolled input stores its current value in the DOM rather than in React state on every keystroke. React Hook Form uses this pattern by default for native fields.
<input {...register("firstName")} />
This is different from a controlled input:
const [firstName, setFirstName] = useState("");
<input
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
/>;
Uncontrolled inputs are useful because:
- The field can update without forcing the parent component to rerender on every keystroke.
- The browser keeps native input behavior.
- Large forms can stay responsive.
- Form state subscriptions can be scoped more carefully.
Uncontrolled does not mean unmanaged. React Hook Form still tracks the field through name, ref, onChange, and onBlur.
register
register returns props that should be spread onto a native input.
<input
{...register("age", {
valueAsNumber: true,
min: { value: 18, message: "Must be at least 18" },
})}
/>
The returned props include:
name.ref.onChange.onBlur.- Native validation-related attributes when applicable.
Rules can include validation and value conversion. For example, valueAsNumber converts the field value before validation and submission. setValueAs can customize conversion.
Common mistake:
<input {...register("email")} value={email} onChange={setEmail} />
This mixes uncontrolled registration with a controlled value without a clear reason. If a field must be controlled, use Controller or useController.
handleSubmit
handleSubmit wraps the form submit event. It runs validation, prevents invalid submission, and calls a valid callback or optional invalid callback.
const onValid = async (values: ProfileFormValues) => {
await updateProfile(values);
};
const onInvalid = (errors: FieldErrors<ProfileFormValues>) => {
console.log(errors);
};
<form onSubmit={handleSubmit(onValid, onInvalid)} />;
Important points:
- The valid callback receives parsed form values.
- The invalid callback receives validation errors.
- Async submit handlers are supported.
isSubmittingcan drive pending UI.- Server errors should be mapped back with
setErrorwhen useful.
defaultValues
defaultValues are the initial values for the form and the baseline for dirty tracking.
const form = useForm<ProfileFormValues>({
defaultValues: {
firstName: "",
lastName: "",
email: "",
},
});
Why defaultValues matter:
isDirtycompares current values against defaults.dirtyFieldsdepends on defaults.reset()returns fields to defaults.- Consistent defaults prevent uncontrolled-to-controlled warnings.
For data loaded from an API, use async defaults or call reset after data arrives.
const { reset } = useForm<ProfileFormValues>({
defaultValues: {
firstName: "",
lastName: "",
},
});
useEffect(() => {
if (profile) {
reset(profile);
}
}, [profile, reset]);
Avoid using undefined as a default value for fields that render input values.
Form State
formState exposes form-level state.
Common properties:
errors: validation errors by field name.isDirty: whether any field differs fromdefaultValues.dirtyFields: fields changed from defaults.touchedFields: fields that received blur.isSubmitting: submit handler is running.isSubmitSuccessful: last submit completed successfully.isSubmitted: the form has been submitted at least once.submitCount: number of submit attempts.isValid: whether the form currently passes validation.isValidating: validation is currently running.validatingFields: fields currently validating.isLoading: async default values are loading.
Example:
const {
formState: { isDirty, isSubmitting, errors },
} = useForm<ProfileFormValues>();
React Hook Form tracks subscriptions to form state. Read only the parts of formState that the component actually needs. Broad subscriptions can cause unnecessary rerenders.
Field State
Sometimes a component only needs state for one field. Use getFieldState, useController, or useFormState instead of subscribing a large parent to the entire form.
const fieldState = getFieldState("email");
if (fieldState.invalid) {
console.log(fieldState.error?.message);
}
Field state commonly includes:
invalid.isDirty.isTouched.isValidating.error.
This is useful for reusable input components that need their own error and touched state.
Controller
Controller is used when a field is controlled by a component that does not expose a normal native input ref and event shape.
Common examples:
- UI library select components.
- Date pickers.
- Autocomplete components.
- Rich text editors.
- Masked inputs.
- Custom toggles.
Example:
import { Controller, useForm } from "react-hook-form";
type FormValues = {
startDate: Date | null;
};
function EventForm() {
const { control, handleSubmit } = useForm<FormValues>({
defaultValues: {
startDate: null,
},
});
return (
<form onSubmit={handleSubmit(console.log)}>
<Controller
control={control}
name="startDate"
render={({ field, fieldState }) => (
<>
<DatePicker
selected={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
ref={field.ref}
/>
{fieldState.error ? (
<p role="alert">{fieldState.error.message}</p>
) : null}
</>
)}
/>
</form>
);
}
The render function receives:
field:name,value,onChange,onBlur,ref, and sometimesdisabled.fieldState: error, touched, dirty, validating, and invalid state for that field.formState: broader form state.
Avoiding Double Registration
Do not use register and Controller on the same field.
Bad:
<Controller
name="country"
control={control}
render={({ field }) => (
<Select {...field} {...register("country")} />
)}
/>
The field is already registered by Controller. Double registration can cause conflicting event handling, incorrect state, or confusing bugs.
Mapping Controlled Components
Third-party inputs often do not use the same event signature as native inputs. Controller lets you adapt them.
<Controller
control={control}
name="price"
render={({ field }) => (
<CurrencyInput
value={field.value}
onValueChange={(value) => field.onChange(value)}
onBlur={field.onBlur}
inputRef={field.ref}
/>
)}
/>
The important part is to pass the final field value to field.onChange. Do not pass a complex event object unless the component is designed that way.
watch, getValues, and setValue
watch subscribes to values and can rerender the component when watched values change.
const plan = watch("plan");
Use it for conditional UI:
{plan === "business" ? <BusinessFields /> : null}
getValues reads current values without creating a render subscription.
const currentValues = getValues();
setValue updates a field imperatively and can optionally update validation, dirty, and touched state.
setValue("email", normalizedEmail, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
Use these intentionally. Too much imperative form manipulation can make the form harder to reason about.
reset and resetField
reset replaces form values and can preserve selected state.
reset(serverValues);
After a successful save, reset to the saved values so dirty tracking reflects changes after the save point:
await saveProfile(values);
reset(values);
resetField resets one field.
resetField("email");
Common mistake: saving successfully but not resetting defaults. The form still appears dirty even though the current values match the saved server state.
Server Errors with setError
Server validation errors should be mapped into form errors when possible.
try {
await updateProfile(values);
} catch (error) {
if (isValidationError(error)) {
for (const fieldError of error.fields) {
setError(fieldError.name as keyof ProfileFormValues, {
type: "server",
message: fieldError.message,
});
}
return;
}
setError("root.server", {
type: "server",
message: "Could not save profile. Try again.",
});
}
Field-specific errors belong near the field. Form-level errors belong near the submit area or top of the form.
Accessibility
React Hook Form gives state and handlers, but the markup still needs to be accessible.
Good practices:
- Use real
<form>and<button type="submit">. - Use
<label>orhtmlForfor fields. - Use
aria-invalidwhen a field has an error. - Connect errors with
aria-describedby. - Use
role="alert"for important validation messages. - Keep keyboard behavior intact.
- Focus the first invalid field when appropriate.
Example:
<input
id="email"
aria-invalid={Boolean(errors.email)}
aria-describedby={errors.email ? "email-error" : undefined}
{...register("email", { required: "Email is required" })}
/>
{errors.email ? (
<p id="email-error" role="alert">
{errors.email.message}
</p>
) : null}
Performance
React Hook Form's performance advantage comes from avoiding unnecessary rerenders, but it is still possible to lose that benefit.
Performance-friendly practices:
- Prefer
registerfor native inputs. - Use
Controlleronly when a component must be controlled. - Subscribe to the smallest needed state.
- Keep reusable field components scoped.
- Avoid watching the whole form unless needed.
- Avoid storing every field value again in React state.
- Use
defaultValuesconsistently. - Split very large forms into sections.
Do not turn a React Hook Form into a fully controlled form unless there is a clear need.
Common Mistakes
Common mistakes include:
- Mixing
valueandonChangestate withregisteraccidentally. - Double-registering a field with both
registerandController. - Forgetting
defaultValues, causing dirty tracking problems. - Reading broad
formStatein a top-level component and causing extra rerenders. - Using
watch()for the whole form when only one field is needed. - Not forwarding
reforonBlurfromController. - Not resetting defaults after a successful save.
- Treating client validation as a replacement for server validation.
- Showing errors without accessible labels or descriptions.
Best Practices
Best practices include:
- Use uncontrolled inputs with
registerfor native fields. - Use
Controllerfor controlled third-party components. - Provide complete
defaultValues. - Keep field components focused and reusable.
- Subscribe only to the form state you need.
- Map server validation errors with
setError. - Use
isSubmittingto prevent duplicate submits. - Reset after successful saves when current values become the new baseline.
- Use accessible labels, errors, and focus behavior.
- Keep server authorization and validation authoritative.