Build complex forms with minimal re-renders, built in validation, and far less code than controlled inputs.

Bishal Biswas
Self Learner
The problem with controlled forms
function SignupForm() {
const [form, setForm] = useState({ name: '', email: '', password: '' });
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [isSubmitting, setIsSubmitting] = useState(false);
}
Four pieces of state, and every keystroke re-renders the whole form. With ten fields and a live validation summary, typing starts to feel heavy.
React Hook Form takes a different approach: keep the values in uncontrolled inputs, subscribe only where needed, and re-render as little as possible.
The basics
npm install react-hook-form
import { useForm } from 'react-hook-form';
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm();
async function onSubmit(data) {
await createAccount(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name', { required: 'Name is required' })} />
{errors.name && <p className="error">{errors.name.message}</p>}
<input
type="email"
{...register('email', {
required: 'Email is required',
pattern: { value: /\S+@\S+\.\S+/, message: 'That does not look like an email' }
})}
/>
{errors.email && <p className="error">{errors.email.message}</p>}
<button disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Sign up'}
</button>
</form>
);
}
No useState, no onChange, no preventDefault. register connects an input by name, and handleSubmit validates before calling your function with clean data.
Why it is fast
register uses refs, so the value lives in the DOM. Typing does not set state, so nothing re-renders.
The form re-renders only when something you subscribed to changes, such as an error appearing or the submitting flag flipping.
For a form with twenty fields, that is the difference between hundreds of renders and a handful.
Validation rules
{...register('username', {
required: 'Username is required',
minLength: { value: 3, message: 'At least 3 characters' },
maxLength: { value: 20, message: 'At most 20 characters' },
pattern: { value: /^[a-zA-Z0-9_]+$/, message: 'Letters, numbers and underscores only' },
validate: (value) => value !== 'admin' || 'That username is reserved'
})}
validate takes a function returning true or an error message. It can be async, which covers checking availability against a server:
{...register('username', {
validate: async (value) => {
const taken = await checkUsername(value);
return !taken || 'That username is already taken';
}
})}
Comparing two fields:
const { watch } = useForm();
<input
type="password"
{...register('confirmPassword', {
validate: (value) => value === watch('password') || 'Passwords do not match'
})}
/>
When validation runs
const { register } = useForm({ mode: 'onBlur' });
onSubmitis the default. Nothing is validated until they submit.onBlurvalidates when a field loses focus.onChangevalidates on every keystroke.onTouchedvalidates on blur, then on change afterwards.
onTouched is usually the best experience. The user is not told they are wrong before they have finished typing, but once a field has errored they get immediate feedback as they fix it.
Watching values
const { watch } = useForm();
const plan = watch('plan');
return (
<div>
<select {...register('plan')}>
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>
{plan === 'pro' && <input {...register('cardNumber')} />}
</div>
);
watch subscribes to a field, so the component re-renders when it changes. Use it only for the fields that actually drive the interface, since each one costs renders.
For reading a value without subscribing:
const { getValues } = useForm();
function handleSomething() {
const email = getValues('email');
}
Dynamic fields
import { useFieldArray } from 'react-hook-form';
function InvoiceForm() {
const { register, control, handleSubmit } = useForm({
defaultValues: { items: [{ name: '', price: 0 }] }
});
const { fields, append, remove } = useFieldArray({ control, name: 'items' });
return (
<form onSubmit={handleSubmit(onSubmit)}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`items.${index}.name`)} />
<input type="number" {...register(`items.${index}.price`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ name: '', price: 0 })}>
Add item
</button>
</form>
);
}
Note key={field.id}, not the index. useFieldArray provides a stable id for exactly the reason covered in the lists topic.
Custom components
register needs a real input. For a third party select or date picker, use Controller:
import { Controller } from 'react-hook-form';
<Controller
name="country"
control={control}
rules={{ required: 'Please choose a country' }}
render={({ field, fieldState }) => (
<>
<CustomSelect {...field} options={countries} />
{fieldState.error && <p>{fieldState.error.message}</p>}
</>
)}
/>
field contains value, onChange, onBlur, name and ref, so spreading it usually connects a well behaved component.
Server errors
const { setError } = useForm();
async function onSubmit(data) {
try {
await createAccount(data);
} catch (error) {
if (error.field) {
setError(error.field, { message: error.message });
} else {
setError('root', { message: 'Something went wrong' });
}
}
}
setError('root', ...) is for form level errors that do not belong to a field.
With a schema
Rules on every field get repetitive, and they cannot be shared with the server. A schema library fixes both, which is the next topic.
Quick recap
- Controlled forms re-render on every keystroke, which does not scale.
- React Hook Form keeps values in the DOM and subscribes narrowly.
registerconnects an input,handleSubmitvalidates then calls you.validateaccepts a function, including an async one.mode: 'onTouched'is usually the kindest validation timing.watchsubscribes and costs renders,getValuesdoes not.useFieldArrayhandles dynamic lists, with stablefield.idkeys.Controllerwraps components that are not plain inputs.
import { useForm, useFieldArray, Controller } from 'react-hook-form';
export default function SignupForm() {
const {
register, // connects an input by name, using refs not state
handleSubmit, // validates, then calls your function with clean data
control, // for Controller and useFieldArray
watch, // subscribe to a field, costs a render
getValues, // read without subscribing
setError,
formState: { errors, isSubmitting }
} = useForm({
mode: 'onTouched', // kindest timing: blur first, then live
defaultValues: { plan: 'free', items: [{ name: '', price: 0 }] }
});
const { fields, append, remove } = useFieldArray({ control, name: 'items' });
const plan = watch('plan'); // drives the UI, so worth subscribing
async function onSubmit(data) {
try {
await createAccount(data);
} catch (error) {
// Map a server error back onto the right field, or the form
setError(error.field ?? 'root', { message: error.message });
}
}
return (
// No useState, no onChange, no preventDefault. Typing does not
// re-render the form at all.
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('username', {
required: 'Username is required',
minLength: { value: 3, message: 'At least 3 characters' },
// validate can be async, so it can check the server
validate: async (value) =>
(await isAvailable(value)) || 'That username is taken'
})}
/>
{errors.username && <p className="error">{errors.username.message}</p>}
<input
type="password"
{...register('confirmPassword', {
validate: (value) =>
value === watch('password') || 'Passwords do not match'
})}
/>
<select {...register('plan')}>
<option value="free">Free</option>
<option value="pro">Pro</option>
</select>
{plan === 'pro' && <input {...register('cardNumber')} />}
{/* Dynamic rows. key is field.id, NEVER the index. */}
{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: '', price: 0 })}>
Add item
</button>
{/* Controller wraps anything that is not a plain input */}
<Controller
name="country"
control={control}
rules={{ required: 'Please choose a country' }}
render={({ field, fieldState }) => (
<>
<CustomSelect {...field} options={countries} />
{fieldState.error && <p>{fieldState.error.message}</p>}
</>
)}
/>
{errors.root && <p className="error">{errors.root.message}</p>}
<button disabled={isSubmitting}>
{isSubmitting ? 'Creating...' : 'Sign up'}
</button>
</form>
);
}
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready