Fix: React Hook Form Not Working — register Not Applying, Validation Not Triggering, or Controller Issues
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 uses uncontrolled inputs by default — it registers a ref on each input to read values directly from the DOM. Several things break this:
- Missing
registerspread —register('fieldName')returns{ name, ref, onChange, onBlur }. You must spread all of these onto the input. Passing onlyrefor onlynameloses the event bindings. - Wrong validation trigger mode — by default, RHF only validates on submit (
mode: 'onSubmit'). If you’re expecting real-time validation, setmode: 'onChange'ormode: 'onBlur'. - UI library components — controlled components from UI libraries (MUI
TextField, Ant DesignSelect, etc.) often manage their own refs and don’t work with RHF’s uncontrolled approach. UseControllerfor these. - Nested objects vs flat names —
register('user.email')creates a nested value{ user: { email: '...' } }. Accessingerrors.user.emailrequires dot notation orget(errors, 'user.email').
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 wraps controlled components and bridges them with RHF:
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.
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.