Fix: React Hook Form Not Working — register Not Applying, Validation Not Triggering, or Controller Issues
Part of: React & Frontend Errors
Quick Answer
How to fix React Hook Form issues — register spread syntax, Controller for UI libraries, validation modes, watch vs getValues, nested fields, and form submission errors.
The Problem
register() doesn’t bind to the input:
const { register } = useForm();
// Input value is never captured
<input name="email" /> // Missing register — form data will be empty
// Or incorrect spread
const emailRef = register('email');
<input ref={emailRef} /> // Missing name, onChange, onBlurOr validation never fires despite rules being set:
const { register, handleSubmit, formState: { errors } } = useForm();
<input {...register('email', { required: true })} />
{errors.email && <p>Email is required</p>} // Never shows
// Form submits without validation
<form onSubmit={handleSubmit(onSubmit)}>Or a UI library component (MUI, Ant Design, shadcn/ui) doesn’t integrate with register:
// TextField from MUI — register doesn't work directly
<TextField {...register('email')} /> // Value is never capturedWhy This Happens
React Hook Form is built on a fundamentally different model from useState-based form libraries. Most form libraries keep field values in React state, which means every keystroke re-renders the component that owns the form. RHF avoids that by using uncontrolled inputs: each <input> keeps its own value in the DOM, and RHF holds a ref to read the value when needed. The benefit is performance — typing in a 50-field form does not re-render the form on every keystroke. The cost is that anything which prevents RHF from attaching its ref, onChange, and onBlur breaks the binding silently.
That model is the root cause of the four most common ways RHF “doesn’t work”:
- Missing
registerspread.register('fieldName')returns an object with{ name, ref, onChange, onBlur }. You must spread all four properties onto the input. Passing onlyrefor onlynamewill compile fine and render fine, but the form data will be empty on submit because RHF never gets notified of changes. Most “register isn’t working” reports trace back to a<input ref={register('email').ref} />instead of<input {...register('email')} />. - Wrong validation trigger mode. By default RHF validates on submit (
mode: 'onSubmit') and shows no errors before that. If your tests or visual design expect real-time feedback, setmode: 'onChange'ormode: 'onBlur'when callinguseForm().mode: 'onTouched'is usually the right choice for production UX — it shows errors only after the user has interacted with a field. - UI library components. Controlled components from MUI, Ant Design, Mantine, Chakra, and many others manage their own internal state and ref. They cannot be bound with
registerdirectly because they do not forward the DOMrefand they replace the nativeonChangesignature with a custom one (e.g., MUI passes the value, not the event). For these you needController, which adapts RHF’s API to whatever shape the component expects. - Nested objects vs flat names.
register('user.email')does not create a flat field named"user.email"— it creates a nested object{ user: { email: '...' } }in the form values, and the errors object mirrors that shape. Accessingerrors.user?.emailrequires the optional chain becauseerrors.userisundefineduntil the field is touched and has an error.
A subtler trap: RHF’s register is stable across renders, which makes it safe to call inside .map() for dynamic field arrays. But the resulting ref/onChange/onBlur bindings are recomputed every render, which means mounting and unmounting the input destroys its registered state. Conditionally rendered fields with {showField && <input {...register('foo')} />} need either useFieldArray (for true dynamic lists) or shouldUnregister: false (to keep the value after unmount).
Fix 1: Spread All register Return Values
register() returns multiple properties — spread all of them:
import { useForm } from 'react-hook-form';
type FormValues = {
email: string;
password: string;
age: number;
};
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>();
const onSubmit = (data: FormValues) => {
console.log(data); // { email: '...', password: '...', age: 25 }
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* WRONG — only name, missing ref/onChange/onBlur */}
<input name="email" />
{/* CORRECT — spread all register props */}
<input
type="email"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Invalid email address',
},
})}
/>
{errors.email && <p>{errors.email.message}</p>}
<input
type="password"
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
})}
/>
{errors.password && <p>{errors.password.message}</p>}
{/* Number input — use valueAsNumber */}
<input
type="number"
{...register('age', {
valueAsNumber: true,
min: { value: 18, message: 'Must be 18 or older' },
})}
/>
{errors.age && <p>{errors.age.message}</p>}
<button type="submit">Submit</button>
</form>
);
}Fix 2: Set Validation Mode
Control when validation runs:
import { useForm } from 'react-hook-form';
const form = useForm<FormValues>({
mode: 'onSubmit', // Default — only on submit
mode: 'onBlur', // Validate when field loses focus
mode: 'onChange', // Validate on every keystroke (can be slow)
mode: 'onTouched', // First on blur, then on change after first blur
mode: 'all', // onChange + onBlur
reValidateMode: 'onChange', // After first submit error, re-validate on change
});Per-field validation trigger:
// Validate this field on blur regardless of global mode
<input
{...register('email', {
required: true,
// validation rules apply with whatever mode is set globally
})}
/>
// Manually trigger validation
const { trigger } = useForm();
await trigger('email'); // Validate one field
await trigger(['email', 'password']); // Validate multiple
await trigger(); // Validate all fieldsFix 3: Use Controller for UI Library Components
Controller exists for one reason: not every input is an uncontrolled HTML <input>. MUI TextField wraps the input in two layers of div with its own focus handling, MUI DatePicker uses a synthetic onChange that receives a Date object, and shadcn/ui’s Select does not expose a native input at all. Controller wraps these components and bridges them with RHF — it manages a controlled value internally and feeds it back through RHF’s machinery, sacrificing a small amount of performance for compatibility.
The rule of thumb: if the component is built on a native <input> and forwards its ref, use register. If the component manages its own value (any time you would otherwise reach for useState), use Controller.
import { useForm, Controller } from 'react-hook-form';
import { TextField, Select, MenuItem, Checkbox } from '@mui/material';
import { DatePicker } from '@mui/x-date-pickers';
type FormValues = {
email: string;
country: string;
birthdate: Date | null;
newsletter: boolean;
};
export function ProfileForm() {
const { control, handleSubmit, formState: { errors } } = useForm<FormValues>({
defaultValues: {
email: '',
country: '',
birthdate: null,
newsletter: false,
},
});
return (
<form onSubmit={handleSubmit(console.log)}>
{/* MUI TextField */}
<Controller
name="email"
control={control}
rules={{ required: 'Email is required' }}
render={({ field, fieldState }) => (
<TextField
{...field}
label="Email"
error={!!fieldState.error}
helperText={fieldState.error?.message}
/>
)}
/>
{/* MUI Select */}
<Controller
name="country"
control={control}
rules={{ required: 'Select a country' }}
render={({ field }) => (
<Select {...field} displayEmpty>
<MenuItem value="">Choose...</MenuItem>
<MenuItem value="us">United States</MenuItem>
<MenuItem value="uk">United Kingdom</MenuItem>
</Select>
)}
/>
{/* MUI DatePicker */}
<Controller
name="birthdate"
control={control}
render={({ field }) => (
<DatePicker
label="Birth date"
value={field.value}
onChange={field.onChange}
inputRef={field.ref}
/>
)}
/>
{/* Checkbox */}
<Controller
name="newsletter"
control={control}
render={({ field }) => (
<Checkbox
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
/>
)}
/>
<button type="submit">Save</button>
</form>
);
}shadcn/ui integration:
import { Controller } from 'react-hook-form';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
// shadcn/ui Input — works directly with register
<Input {...register('email')} />
// shadcn/ui Select — needs Controller
<Controller
name="role"
control={control}
render={({ field }) => (
<Select onValueChange={field.onChange} defaultValue={field.value}>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
)}
/>Fix 4: Handle Nested Fields and Arrays
RHF supports complex data shapes with dot notation and useFieldArray:
import { useForm, useFieldArray } from 'react-hook-form';
type FormValues = {
user: {
name: string;
address: {
street: string;
city: string;
};
};
tags: { value: string }[];
};
export function ComplexForm() {
const { register, control, handleSubmit, formState: { errors } } = useForm<FormValues>();
const { fields, append, remove } = useFieldArray({
control,
name: 'tags',
});
return (
<form onSubmit={handleSubmit(console.log)}>
{/* Nested field with dot notation */}
<input {...register('user.name', { required: true })} />
{errors.user?.name && <p>Name required</p>}
<input {...register('user.address.street')} />
<input {...register('user.address.city')} />
{/* Dynamic array fields */}
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`tags.${index}.value`)} />
<button type="button" onClick={() => remove(index)}>Remove</button>
</div>
))}
<button type="button" onClick={() => append({ value: '' })}>
Add Tag
</button>
<button type="submit">Submit</button>
</form>
);
}Fix 5: Watch Values and Set Defaults
Reading form values reactively and setting initial data:
const {
register,
watch,
getValues,
setValue,
reset,
} = useForm<FormValues>({
defaultValues: {
email: '',
role: 'user',
notifications: true,
},
});
// watch() — reactive, causes re-render on every change
const email = watch('email');
const allValues = watch(); // Watch all fields
// getValues() — non-reactive, doesn't cause re-render
// Use inside event handlers and callbacks
const handlePreview = () => {
const currentValues = getValues();
showPreview(currentValues);
};
// setValue() — programmatically set a value
setValue('email', '[email protected]');
setValue('email', '[email protected]', {
shouldValidate: true, // Trigger validation
shouldDirty: true, // Mark field as dirty
});
// reset() — reset to defaultValues or new values
reset(); // Reset to defaultValues
reset({ email: '[email protected]', role: 'admin' }); // Reset to new values
// Load async data into form
useEffect(() => {
if (userData) {
reset(userData); // Populate form with fetched data
}
}, [userData, reset]);Fix 6: Integrate with Zod for Schema Validation
Use Zod (or Yup) for declarative, reusable validation:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Password must be at least 8 characters'),
confirmPassword: z.string(),
age: z.number().min(18, 'Must be 18 or older'),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'Passwords must match',
path: ['confirmPassword'], // Error appears on confirmPassword field
}
);
type FormValues = z.infer<typeof schema>;
export function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: {
email: '',
password: '',
confirmPassword: '',
age: 0,
},
});
const onSubmit = async (data: FormValues) => {
await registerUser(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input type="email" {...register('email')} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register('password')} />
{errors.password && <p>{errors.password.message}</p>}
<input type="password" {...register('confirmPassword')} />
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
<input
type="number"
{...register('age', { valueAsNumber: true })}
/>
{errors.age && <p>{errors.age.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Registering...' : 'Register'}
</button>
</form>
);
}Still Not Working?
formState.errors is empty but form doesn’t submit — check if handleSubmit is on the <form> element’s onSubmit, not on the button’s onClick. The handleSubmit wrapper runs validation before calling your submit handler. A surprisingly common variant: the submit button is <button> (default type="submit") inside a form, but the form itself has onSubmit missing, so pressing Enter does nothing while clicking the button reloads the page.
formState values seem one render behind — RHF intentionally subscribes to formState lazily so that reading individual fields does not trigger unnecessary re-renders. If you destructure const { errors } = formState but never reference formState.errors in the render output, RHF may not re-render when errors change. Either reference the value in JSX ({errors.email && ...}) or call useFormState({ control }) explicitly.
Values reset to '' on re-render after an async fetch — RHF picks up defaultValues only at mount. To populate the form from data that loads later, call reset(data) inside a useEffect, not by passing the new data as defaultValues on a subsequent render.
register in a custom input component — if you wrap an input in a custom component, forward the ref using React.forwardRef, or use Controller instead:
register in a custom input component — if you wrap an input in a custom component, forward the ref using React.forwardRef, or use Controller instead:
// Custom input that works with register
const CustomInput = React.forwardRef<HTMLInputElement, InputProps>(
({ onChange, onBlur, name, label }, ref) => (
<div>
<label>{label}</label>
<input ref={ref} name={name} onChange={onChange} onBlur={onBlur} />
</div>
)
);TypeScript errors on nested field paths — use Path<FormValues> for type-safe field names, or use the as const assertion. Template literal paths like `tags.${index}.value` may need as Path<FormValues> in some TypeScript configurations.
For related React issues, see Fix: React Too Many Re-renders and Fix: React useEffect Infinite Loop.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: cmdk Not Working — Command Palette Not Opening, Items Not Filtering, or Keyboard Navigation Broken
How to fix cmdk command palette issues — Dialog setup, custom filtering, groups and separators, keyboard shortcuts, async search, nested pages, and integration with shadcn/ui and Tailwind.
Fix: Conform Not Working — Form Validation Not Triggering, Server Errors Missing, or Zod Schema Rejected
How to fix Conform form validation issues — useForm setup with Zod, server action integration, nested and array fields, file uploads, progressive enhancement, and Remix and Next.js usage.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: Lingui Not Working — Messages Not Extracted, Translations Missing, or Macro Errors
How to fix Lingui.js i18n issues — setup with React, message extraction, macro compilation, ICU format, lazy loading catalogs, and Next.js integration.