Managing form state has traditionally been one of the most challenging aspects of frontend development. From displaying validation messages to handling complex rules, dynamic form fields, and reset functionality, the complexity can quickly become overwhelming. Fortunately, several robust community solutions have emerged in recent years, including Formik, react-hook-form, and react-final-form. This article focuses on react-hook-form and explores its key features.
The useForm Hook
The useForm hook serves as the foundation for form state management. It accepts configuration options including defaultValues and mode:
const {
handleSubmit,
watch
} = useForm({
defaultValues: {},
mode: 'onSubmit'
})
The mode parameter determines when validation is triggered. Options include onChange, onBlur, onSubmit, onTouched, and all. Setting mode to 'all' provides immediate feedback as users fill out the form.
The defaultValues option is particularly useful when pre-populating forms with data fetched from a backend API.
Validation Rules
Validation rules define the constraints for each form field. The rules object supports the following properties:
{
required: true,
maxLength: 50,
minLength: 2,
max: 100,
min: 0,
pattern: /^[a-zA-Z]+$/,
validate: (value) => value.length > 0,
validate: {
isPositive: (value) => value > 0,
isBelowLimit: (value) => value < 1000
}
}
For multiple validation checks, use the object syntax to define named validators that run independently.
Integrating UI Libraries with Controller
While the register function returned by useForm works seamlessly with native HTML elements, most projects utilize UI component libraries. The Controller component bridges this gap by wrapping third-party components.
Controller accepts control, name, rules, and a render function as props. The render function receives field, fieldState, and formState parameters:
{
field: { onChange, onBlur, value, name, ref },
fieldState: { invalid, isTouched, isDirty, error },
formState
}
The field object contains the handlers needed to control the input, while fieldState provides validation metadata for rendering appropriate UI states.
<Controller
control={control}
rules={{ required: true }}
name="selected"
render={({
field: { onChange, onBlur, value, name, ref },
fieldState: { invalid, isTouched, isDirty, error },
formState
}) => (
<Checkbox
onBlur={onBlur}
onChange={onChange}
checked={value}
inputRef={ref}
/>
)}
/>
Dynamic Forms with watch and useWatch
Common scenarios require that one input influences subsequent form behavior. While useForm records state changes, these updates do not always trigger re-renders. The watch and useWatch hooks ensure UI updates when values change.
Consider a real-time preview requirement where user input should display immediately:
const displayedUsername = watch('username', '');
return (
<div>
<label>Username</label>
<input
type="text"
{...register("username", { required: true, maxLength: 30 })}
/>
<div>Preview: {displayedUsername}</div>
</div>
);
When the input changes, React re-renders the component, displaying the user's current input.
The distinction between watch and useWatch is performance-related. watch is a return value from useForm, while useWatch is a separate hook. In scenarios where child components need to track values without causing parent updates, useWatch delivers better performance. Passing watch to child components triggers updates in both child and parent when values change.
Manual Validation with trigger
By default, validation occurs on blur or form submission. However, certain use cases require validation based on preceding field values. The trigger function enables this pattern.
Imagine a scenario where selecting a province should trigger validation for the city dropdown:
const selectedProvince = watch("province");
useEffect(() => {
if (selectedProvince) {
trigger("city");
}
}, [selectedProvince, trigger]);
This approach ensures that dependent fields are re-validated immediately when their prerequisites change.
Managing Dynamic Field Arrays with useFieldArray
Frequently, applications require users to add or remove form entries dynamically, such as managing multiple shipping addresses. The useFieldArray hook handles this elegantly:
const { register, control, handleSubmit, reset, watch } = useForm({
defaultValues: {
addresses: [{ street: "123 Main", city: "NYC" }]
}
});
const {
fields,
append,
prepend,
remove,
swap,
move,
insert,
replace
} = useFieldArray({
control,
name: "addresses"
});
The prepend function inserts items at the beginning, append adds items at the end, and remove eliminates entries. For scenarios requiring real-time updates of field array contents, combine useFieldArray with watch or useWatch to ensure responsive UI feedback.