You’re building a login form in React. Two inputs — email and password. You reach for useState to track each value, then add two more for error messages. Four state variables for what amounts to two fields. The project scope grows: username, confirm password, a terms-of-service checkbox. Eight state variables. Ten. And on every keystroke in any field, React re-renders the entire form component — including inputs the user isn’t touching.
React hook form sidesteps this. It registers inputs via refs rather than state, so typing into a field generates no re-renders. Validation fires when you need it, and the same login form that required four state variables requires zero.
How React Hook Form Works Differently
The standard React pattern for forms is controlled inputs: every field’s value prop is tied to a state variable, and every onChange event calls a setter. That’s three lines of code per field just to keep the UI in sync, more once you add validation and error display.
React hook form takes an uncontrolled approach instead. useForm() gives you a register() function that attaches a ref to each input. The library reads values directly from the DOM when the form submits — no intermediate state variables, no re-renders while the user types.
The performance difference matters at scale. A controlled form with ten fields re-renders every child component on every keystroke in every field. The equivalent react hook form setup triggers a re-render only when an error is added or cleared — typically on submit and on the first correction.
The core API:
| Function / property | What it does |
|---|---|
useForm(options) | Initialises the form; returns the full API |
register(name, rules) | Wires an input to the form |
handleSubmit(fn) | Runs validation, then calls fn if valid |
formState.errors | Current validation errors, keyed by field name |
setError(name, error) | Manually injects an error (for server responses) |
reset(values) | Resets fields to defaults or new values |
watch(name) | Returns a field’s current value live |
Start with the first five. Controller, useFieldArray, and the full watch() API handle the edge cases you’ll encounter once forms get more complex.
Setting Up react hook form
Install the package — no peer dependencies beyond React:
npm install react-hook-form
No context provider to wrap your app in. No global configuration. Import directly in any component that needs it:
import { useForm } from 'react-hook-form';
TypeScript types ship in the same package, so there is no separate @types/react-hook-form to install. The library targets React 16.8 and newer.
Building a Form: useForm, register, and handleSubmit
Start with the simplest working version — no validation, just the wiring:
import { useForm } from 'react-hook-form';
function LoginForm() {
const { register, handleSubmit } = useForm();
const onSubmit = (data) => {
// data = { email: 'user@example.com', password: 'secret123' }
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} placeholder="Email" />
<input {...register('password')} type="password" placeholder="Password" />
<button type="submit">Log in</button>
</form>
);
}
register('email') returns an object — { name, ref, onChange, onBlur } — which you spread onto the input with {...register('email')}. When the form submits, handleSubmit calls onSubmit with a plain object keyed by the names you passed to register().
Now add validation rules and error display:
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
const onSubmit = (data) => {
// Only called after both fields pass validation
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Enter a valid email address',
},
})}
placeholder="Email"
/>
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div>
<input
{...register('password', {
required: 'Password is required',
minLength: { value: 8, message: 'Minimum 8 characters' },
})}
type="password"
placeholder="Password"
/>
{errors.password && <span className="error">{errors.password.message}</span>}
</div>
<button type="submit">Log in</button>
</form>
);
}
errors.email.message holds the string you passed to whichever rule failed. If the field passes all rules, errors.email is undefined. You render error messages directly from formState.errors — no extra useState for error strings, no manual clearing.
The default validation mode is 'onSubmit': errors appear after the first submit attempt and clear when the user fixes the field and resubmits. Two other modes are useful in practice:
mode: 'onTouched'— validates when the field loses focus; usually the better UX defaultmode: 'onChange'— validates on every keystroke; useful for fields like password strength indicators, but can feel aggressive on email fields while the user is still mid-input
Pass the mode to useForm(): useForm({ mode: 'onTouched' }).
Validation Rules and the validate Escape Hatch
React hook form’s built-in constraint set covers the common field types:
| Rule | Type | Example |
|---|---|---|
required | boolean or string | required: 'This field is required' |
minLength | { value, message } | minLength: { value: 8, message: 'Min 8 chars' } |
maxLength | { value, message } | maxLength: { value: 50, message: 'Max 50 chars' } |
min | number or { value, message } | For numeric inputs |
max | number or { value, message } | For numeric inputs |
pattern | { value: RegExp, message } | Regex match against the field value |
validate | function or object of functions | Custom logic — runs after built-ins |
Password confirmation — a case the built-in rules can’t handle — goes in validate. Call watch() to read the password field’s current value inside the confirmation field’s rules:
function SignupForm() {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm();
const password = watch('password');
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<div>
<input
{...register('username', {
required: 'Username is required',
minLength: { value: 3, message: 'Minimum 3 characters' },
maxLength: { value: 20, message: 'Maximum 20 characters' },
})}
placeholder="Username"
/>
{errors.username && <span>{errors.username.message}</span>}
</div>
<div>
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Enter a valid email address',
},
})}
placeholder="Email"
/>
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<input
{...register('password', {
required: 'Password is required',
minLength: { value: 8, message: 'Minimum 8 characters' },
})}
type="password"
placeholder="Password"
/>
{errors.password && <span>{errors.password.message}</span>}
</div>
<div>
<input
{...register('confirmPassword', {
required: 'Please confirm your password',
validate: (value) =>
value === password || 'Passwords do not match',
})}
type="password"
placeholder="Confirm password"
/>
{errors.confirmPassword && <span>{errors.confirmPassword.message}</span>}
</div>
<button type="submit">Create account</button>
</form>
);
}
validate receives the field’s current value and should return true, undefined (valid), or a string (the error message to display). You can pass an object of named validators to get multiple distinct error messages for the same field:
validate: {
notEmpty: (v) => v.trim().length > 0 || 'Cannot be blank',
noSpaces: (v) => !/\s/.test(v) || 'Username cannot contain spaces',
}
When building the regex pattern for email or username validation, the regex tester lets you verify the pattern against sample inputs before embedding it in component code.
Handling Server-Side Errors
Client-side validation catches format problems, but some checks require a server round-trip — whether credentials are correct, whether a username is already taken. Use setError() to write server responses back into formState.errors:
function LoginForm() {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm();
const onSubmit = async (data) => {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const body = await response.json();
if (body.field === 'email') {
// Attach the error to the specific field
setError('email', { message: body.message });
} else {
// Non-field error — 'root' is the conventional key
setError('root', { message: 'Invalid email or password' });
}
return;
}
// Success — redirect, update auth state, store token, etc.
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email', { required: 'Email is required' })}
placeholder="Email"
/>
{errors.email && <span className="error">{errors.email.message}</span>}
<input
{...register('password', { required: 'Password is required' })}
type="password"
placeholder="Password"
/>
{errors.password && <span className="error">{errors.password.message}</span>}
{errors.root && <p className="form-error">{errors.root.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Logging in…' : 'Log in'}
</button>
</form>
);
}
isSubmitting is true while handleSubmit’s async callback is running — disabling the button on it blocks double-submit without any extra useState. The root key for non-field errors is a convention react hook form treats no differently from any other field name, but it reads clearly in components.
For the full list of HTTP status codes your API might return — 401 Unauthorized, 422 Unprocessable Entity, 500 Internal Server Error — the HTTP Status Codes reference covers each class with practical context.
TypeScript and React Hook Form
TypeScript support is first-class and requires no extra configuration. Define an interface for your form data, then pass it as a generic to useForm():
interface LoginFormData {
email: string;
password: string;
}
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>();
const onSubmit = (data: LoginFormData) => {
// data.email and data.password are typed as string
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email', { required: 'Email is required' })} />
{errors.email && <span>{errors.email.message}</span>}
<input
{...register('password', { required: 'Password is required' })}
type="password"
/>
{errors.password && <span>{errors.password.message}</span>}
<button type="submit">Log in</button>
</form>
);
}
TypeScript now enforces that the first argument to register() is a valid key in LoginFormData. A typo like register('pasword') becomes a compile error instead of a silent miss at runtime. The errors object is typed accordingly — errors.email is always FieldError | undefined, never an unexpected shape.
If you’re using a schema validation library, the @hookform/resolvers package connects Zod, Yup, Joi, and others to useForm() via the resolver option, replacing the inline rules object on each register() call. The react-hook-form documentation covers the full resolver setup with TypeScript types included.
Three Things That Will Trip You Up
Trap 1: Passing register() as a prop instead of spreading it
// Broken — none of the required props are set on the input
<input register={register('email')} />
// Correct — spreads name, ref, onChange, and onBlur onto the input
<input {...register('email')} />
register() returns a plain object. Spreading it is what attaches the ref and event handlers that connect the input to react hook form’s internal tracking. Without the spread, the field exists in the form’s schema but never reports its value — validation silently operates on an empty string.
Trap 2: Setting initial values from async data without reset()
Uncontrolled inputs read their initial value from defaultValues passed to useForm() at mount time. If you fetch profile data after the component mounts and try to update the form via state, the inputs stay at whatever they were initialised with:
// Won't update the fields — inputs are already mounted with their initial (undefined) values
const { register } = useForm({ defaultValues: asyncUserData });
Use reset() after the data arrives instead:
const { register, reset } = useForm();
useEffect(() => {
fetchUserProfile().then((profile) => reset(profile));
}, [reset]);
reset() updates both the library’s internal form state and the DOM inputs atomically, keeping everything in sync.
Trap 3: Forgetting that errors don’t clear automatically while typing
In the default 'onSubmit' mode, errors appear on submit and stay visible until the next submit — even after the user corrects the value and tabs to the next field. A user who fixes their email address and immediately tabs out still sees the error.
Switching to mode: 'onTouched' means each field’s error clears the first time the user interacts with it after the error appeared. It’s a meaningful improvement in perceived form responsiveness without the noise of validating every keystroke. Set it once at the useForm() call:
const { register, handleSubmit, formState: { errors } } = useForm({ mode: 'onTouched' });
Frequently Asked Questions
What is the difference between React Hook Form and Formik?
React Hook Form registers inputs via refs — the uncontrolled approach — and only re-renders when validation state changes. Formik uses controlled inputs, so every keystroke calls setState and triggers a component re-render. For most forms this makes React Hook Form noticeably faster, with a smaller bundle size and a leaner API surface. Formik’s value tracking is more explicit, which some teams find easier to debug when forms get complex. For new projects, react hook form is the better default; Formik remains a reasonable choice if your team has significant existing patterns built around it.
How do I validate a field asynchronously — for example, checking if a username is taken?
Pass an async function to the validate option inside the rules object:
{...register('username', {
validate: async (value) => {
const res = await fetch(`/api/check-username?username=${encodeURIComponent(value)}`);
const { available } = await res.json();
return available || 'Username is already taken';
},
})}
React Hook Form awaits the function before setting the error or allowing the submit handler to run. The async validator should return true or undefined when the value is valid, or a string containing the error message when it is not. You can also use the MDN constraint validation API as a reference for standard HTML5 validation constraints react hook form wraps.
Can I use React Hook Form with UI libraries like MUI or Chakra UI that don’t expose a native ref?
Yes — use the Controller component for any input that doesn’t forward a native ref:
import { Controller, useForm } from 'react-hook-form';
import { TextField } from '@mui/material';
function ProfileForm() {
const { control, handleSubmit } = useForm();
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<Controller
name="displayName"
control={control}
rules={{ required: 'Display name is required' }}
render={({ field, fieldState }) => (
<TextField
{...field}
label="Display name"
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
<button type="submit">Save</button>
</form>
);
}
Controller manages the bridge between react hook form’s ref-based registration and the component’s standard value/onChange interface. The render prop receives both field (value, onChange, onBlur, ref) and fieldState (error, isDirty, isTouched) — spread field onto the component and use fieldState for error display.
Conclusion
React hook form removes the repetitive parts of writing forms in React: state variables for each field value, separate state for each error message, manual clearing when the user corrects input. The ref-based approach keeps re-renders out of the typing path, and the register() API keeps validation logic readable directly on the field.
From here, the natural next step is handling what happens after a valid submit — typically navigating to another page. The React Router guide covers useNavigate and how to redirect after form submission. For a grounding in how forms sit inside the React component model, React Components: JSX, Props, and State is the foundation the entire React spoke builds on. If your login form issues JSON Web Tokens after authentication, What is JWT? explains the format, the claims structure, and how to store tokens safely on the client.