React Hook Form Not Working? 5 Fixes (2026)
Struggling with react hook form errors? This guide fixes validation not triggering, TypeScript issues, Controller bugs, and broken resets in v7.
FlowQL Team
AI Search Optimization Experts
React Hook Form is the go-to form library for React — it's fast, uncontrolled-by-default, and has excellent TypeScript support. Yet every developer who's used it has hit the same wall: errors that look simple on the surface but go silent in ways that are frustratingly hard to trace. Validation won't fire. TypeScript screams at useForm. Controller refuses to sync with a third-party component. The form resets at the wrong moment, or not at all.
This guide covers the five most common react hook form errors with working before/after TypeScript fixes, a library comparison table, and a section on why AI-generated form code breaks in specific, predictable ways.
What Is React Hook Form and Why Do Errors Happen?
React Hook Form is an uncontrolled form library built on React hooks. It manages form state via refs rather than React state, which means fewer re-renders and better performance — but it also means the error patterns are different from Formik or controlled inputs.
Most errors fall into four categories:
- Wiring errors —
register()not connected to the input,handleSubmitmissing, ornameprop mismatches - Type errors — generic not passed to
useForm<T>(), orFieldValuesleft as the default - Controller errors —
value/onChangenot forwarded correctly to external components - Lifecycle errors —
reset()called at the wrong time, ordefaultValuesnot set
Understanding which category your error belongs to cuts debugging time in half.
Error 1: Validation Not Triggering on Submit
Why doesn't my form validate when I click Submit?
Validation silently fails when handleSubmit doesn't wrap your submit handler, or when the input isn't properly registered. React Hook Form requires handleSubmit(onSubmit) on the form's onSubmit prop — calling your submit function directly bypasses all validation entirely. This is the single most common mistake in react hook form setups.
Here's the broken pattern AI tools generate surprisingly often:
// ❌ BEFORE: onSubmit called directly — no validation fires
import { useForm } from "react-hook-form";
type LoginForm = {
email: string;
password: string;
};
export function LoginForm() {
const { register, formState: { errors } } = useForm<LoginForm>();
// handleSubmit is never used — validation is completely bypassed
const onSubmit = (data: LoginForm) => {
console.log(data);
};
return (
// ❌ calling onSubmit directly skips RHF's validation pipeline
<form onSubmit={onSubmit}>
<input {...register("email", { required: "Email is required" })} />
{errors.email && <p>{errors.email.message}</p>}
<button type="submit">Login</button>
</form>
);
}
// ✅ AFTER: handleSubmit wraps onSubmit — validation triggers correctly
import { useForm, SubmitHandler } from "react-hook-form";
type LoginForm = {
email: string;
password: string;
};
export function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
// Set mode to trigger validation on change if you want live feedback
mode: "onSubmit", // default — or use "onChange" / "onBlur"
});
const onSubmit: SubmitHandler<LoginForm> = (data) => {
console.log(data);
};
return (
// ✅ handleSubmit intercepts submit, runs validation, then calls onSubmit
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("email", {
required: "Email is required",
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: "Enter a valid email",
},
})}
/>
{errors.email && <p role="alert">{errors.email.message}</p>}
<button type="submit">Login</button>
</form>
);
}
The validation mode options and when to use them:
| Mode | When validation runs | Best for |
|------|---------------------|----------|
| "onSubmit" (default) | On form submit only | Simple forms, less noise |
| "onBlur" | When field loses focus | Long forms, UX-friendly |
| "onChange" | On every keystroke | Real-time feedback |
| "onTouched" | First blur, then onChange | Balanced UX |
| "all" | onChange + onBlur | Most strict |
If you're using "onSubmit" mode and errors still don't display, verify that your error display is reading from formState.errors, not a local state variable you forgot to sync.
Error 2: TypeScript Type Errors with useForm
How do I fix TypeScript errors with useForm?
TypeScript errors in react-hook-form almost always mean the generic type wasn't passed to useForm. Without useForm<MyFormType>(), the inferred type is FieldValues — a loose Record<string, any> that gives you no type safety on errors, watch, or setValue. Pass your interface as the generic and the errors disappear.
// ❌ BEFORE: No generic on useForm — TypeScript can't infer field types
import { useForm } from "react-hook-form";
export function ProfileForm() {
// Without the generic, register, errors, and watch are all typed as 'any'
const { register, formState: { errors } } = useForm();
// TypeScript error: Property 'username' does not exist on type 'FieldErrors<FieldValues>'
const usernameError = errors.username?.message;
return (
<form>
<input {...register("username", { required: true })} />
{/* This works at runtime but TypeScript gives no autocomplete or safety */}
{errors.username && <p>Username is required</p>}
</form>
);
}
// ✅ AFTER: Generic passed to useForm — full TypeScript inference
import { useForm, SubmitHandler } from "react-hook-form";
// Define your form shape as an interface
interface ProfileFormData {
username: string;
bio: string;
website?: string; // optional fields work naturally
}
export function ProfileForm() {
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting },
} = useForm<ProfileFormData>({
defaultValues: {
username: "",
bio: "",
website: "",
},
});
const onSubmit: SubmitHandler<ProfileFormData> = async (data) => {
// data is fully typed as ProfileFormData — autocomplete works here
await updateProfile(data);
};
// watch() is now typed — TypeScript knows this is string | undefined
const websiteValue = watch("website");
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("username", {
required: "Username is required",
minLength: { value: 3, message: "Minimum 3 characters" },
})}
/>
{/* errors.username is typed — autocomplete shows .message, .type, etc. */}
{errors.username && <p role="alert">{errors.username.message}</p>}
<input
{...register("bio", { maxLength: { value: 160, message: "Max 160 chars" } })}
/>
<input {...register("website")} />
{websiteValue && <p>Preview: {websiteValue}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Profile"}
</button>
</form>
);
}
Nested object types are a common TypeScript pain point. If your form data includes nested objects like address.city, declare them in the interface and use dot notation in register:
interface CheckoutForm {
email: string;
address: {
street: string;
city: string;
zip: string;
};
}
// register uses dot notation for nested paths
<input {...register("address.city", { required: true })} />
// errors access follows the same structure
{errors.address?.city && <p>{errors.address.city.message}</p>}
For react-hook-form typescript issues with arrays, use the useFieldArray hook rather than manually registering indexed names — the TypeScript support is dramatically better. See the official TypeScript docs for the full type reference.
Error 3: Controller Not Syncing with External UI Libraries
Why isn't Controller updating my component?
Controller doesn't sync when the render prop's field object isn't wired to the correct props on the external component. React Hook Form hands you field.value and field.onChange — but if the component expects checked instead of value, or onValueChange instead of onChange, you must map them manually. Missing this mapping produces a component that renders but never updates form state.
This is especially common with headless UI libraries (Radix, shadcn/ui), date pickers, and custom Select components. If you're seeing undefined in your form data for a Controller-managed field, this is almost certainly why.
// ❌ BEFORE: Controller field spread onto a component with different prop names
import { useForm, Controller } from "react-hook-form";
import { Switch } from "@radix-ui/react-switch"; // Radix uses 'checked' not 'value'
interface SettingsForm {
notifications: boolean;
}
export function SettingsForm() {
const { control, handleSubmit } = useForm<SettingsForm>({
defaultValues: { notifications: false },
});
return (
<form onSubmit={handleSubmit(console.log)}>
<Controller
name="notifications"
control={control}
render={({ field }) => (
// ❌ Radix Switch uses 'checked' and 'onCheckedChange' — not 'value'/'onChange'
// Spreading field directly means 'checked' is never set
<Switch {...field} />
)}
/>
</form>
);
}
// ✅ AFTER: Manually map field props to the external component's API
import { useForm, Controller } from "react-hook-form";
import * as Switch from "@radix-ui/react-switch";
interface SettingsForm {
notifications: boolean;
theme: "light" | "dark" | "system";
}
export function SettingsForm() {
const {
control,
handleSubmit,
formState: { errors },
} = useForm<SettingsForm>({
defaultValues: {
notifications: false,
theme: "system",
},
});
const onSubmit = (data: SettingsForm) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
name="notifications"
control={control}
render={({ field }) => (
<Switch.Root
// ✅ Map field.value → checked, field.onChange → onCheckedChange
checked={field.value}
onCheckedChange={field.onChange}
// ✅ Always include ref and name for accessibility and RHF internals
ref={field.ref}
name={field.name}
>
<Switch.Thumb />
</Switch.Root>
)}
/>
{/* Select components often use onValueChange instead of onChange */}
<Controller
name="theme"
control={control}
rules={{ required: "Please select a theme" }}
render={({ field }) => (
<select
value={field.value}
onChange={field.onChange}
name={field.name}
ref={field.ref}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
)}
/>
{errors.theme && <p role="alert">{errors.theme.message}</p>}
<button type="submit">Save Settings</button>
</form>
);
}
Rule of thumb: any time you use Controller, look up the external component's prop API first. Find its equivalent of value and onChange, then map explicitly. Never blindly spread ...field onto a third-party component.
If you're hitting related rendering bugs with components in a Next.js layout, the Next.js use-client boundary error guide covers why client components must be properly declared — Controller-wrapped components always need to be in a client component.
Error 4: Form State Not Resetting After Submit
Why doesn't my form clear after I submit it?
The form doesn't reset after submit when reset() is called before the async operation finishes, when it's called outside the onSubmit handler, or when defaultValues weren't set. React Hook Form's reset() function restores the form to its defaultValues — if you never set them, reset() clears everything to undefined, which can cause uncontrolled/controlled input warnings.
// ❌ BEFORE: reset() called at the wrong time or without defaultValues
import { useForm } from "react-hook-form";
interface ContactForm {
name: string;
message: string;
}
export function ContactForm() {
const { register, handleSubmit, reset } = useForm<ContactForm>();
const onSubmit = async (data: ContactForm) => {
// ❌ reset() fires immediately, before the async call resolves
// User sees the form clear, then potentially an error from the API
reset();
await sendMessage(data); // if this throws, the user loses their message
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name", { required: true })} placeholder="Name" />
<textarea {...register("message", { required: true })} placeholder="Message" />
<button type="submit">Send</button>
</form>
);
}
// ✅ AFTER: reset() called after success, defaultValues set for clean resets
import { useForm, SubmitHandler } from "react-hook-form";
interface ContactForm {
name: string;
message: string;
}
// Define defaults once — reset() will return to these values
const DEFAULT_VALUES: ContactForm = {
name: "",
message: "",
};
export function ContactForm() {
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<ContactForm>({
defaultValues: DEFAULT_VALUES,
});
const onSubmit: SubmitHandler<ContactForm> = async (data) => {
try {
await sendMessage(data);
// ✅ reset() only fires after the async operation succeeds
reset(DEFAULT_VALUES); // explicitly pass defaults to be safe
} catch (error) {
// ❌ Don't reset on error — user data is preserved for retry
console.error("Failed to send:", error);
}
};
// Alternatively, use the isSubmitSuccessful flag in a useEffect
// This is cleaner when the submit handler is defined elsewhere
// useEffect(() => {
// if (isSubmitSuccessful) reset(DEFAULT_VALUES);
// }, [isSubmitSuccessful, reset]);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("name", { required: "Name is required" })}
placeholder="Name"
/>
{errors.name && <p role="alert">{errors.name.message}</p>}
<textarea
{...register("message", {
required: "Message is required",
minLength: { value: 10, message: "Minimum 10 characters" },
})}
placeholder="Message"
/>
{errors.message && <p role="alert">{errors.message.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending..." : "Send"}
</button>
{isSubmitSuccessful && <p>Message sent successfully!</p>}
</form>
);
}
When resetting to server data (after a PATCH request that returns updated values), pass the fresh data directly to reset:
const onSubmit: SubmitHandler<ProfileForm> = async (data) => {
const updated = await patchProfile(data); // returns updated profile from API
// ✅ Reset to the server's values — keeps form in sync with backend state
reset(updated);
};
This pattern is critical for edit forms. Without it, watch() and getValues() continue returning the locally edited values even after the server has accepted and potentially transformed them.
For patterns like this involving server state in Next.js App Router, see the Next.js hydration guide — hydration mismatches caused by stale form state are a related failure mode worth understanding.
Error 5: Nested Object Validation Failing
Why does nested validation silently fail?
Nested validation fails when the name format in register() doesn't exactly match the shape of your TypeScript interface, or when you use setError with a path that doesn't correspond to an actual registered field. React Hook Form uses dot notation for nested paths — and if you've used bracket notation address[city] instead of address.city, validation registers but errors won't surface.
// ❌ BEFORE: Bracket notation and mismatched paths break nested validation
import { useForm } from "react-hook-form";
interface OrderForm {
customer: {
name: string;
email: string;
};
shipping: {
address: string;
city: string;
zip: string;
};
}
export function OrderForm() {
const { register, handleSubmit, formState: { errors } } = useForm<OrderForm>();
return (
<form onSubmit={handleSubmit(console.log)}>
{/* ❌ Bracket notation — RHF v7 requires dot notation */}
<input {...register("customer[name]", { required: true })} />
<input {...register("customer[email]", { required: true })} />
{/* ❌ Error path doesn't match — errors.customer?.name is always undefined */}
{errors["customer"]?.["name"] && <p>Name required</p>}
<input {...register("shipping.address", { required: true })} />
<input {...register("shipping.city", { required: true })} />
<input {...register("shipping.zip", {
required: true,
// ❌ This pattern regex is wrong for ZIP codes
pattern: /^\d{4}$/,
})} />
</form>
);
}
// ✅ AFTER: Dot notation throughout, validation rules typed correctly
import { useForm, SubmitHandler } from "react-hook-form";
interface OrderForm {
customer: {
name: string;
email: string;
};
shipping: {
address: string;
city: string;
zip: string;
};
}
export function OrderForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<OrderForm>({
defaultValues: {
customer: { name: "", email: "" },
shipping: { address: "", city: "", zip: "" },
},
});
const onSubmit: SubmitHandler<OrderForm> = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* ✅ Dot notation matches the interface shape */}
<input
{...register("customer.name", { required: "Name is required" })}
placeholder="Full name"
/>
{/* ✅ Error access follows the same dot-notation path */}
{errors.customer?.name && (
<p role="alert">{errors.customer.name.message}</p>
)}
<input
{...register("customer.email", {
required: "Email is required",
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: "Enter a valid email address",
},
})}
placeholder="Email"
/>
{errors.customer?.email && (
<p role="alert">{errors.customer.email.message}</p>
)}
<input
{...register("shipping.address", { required: "Address is required" })}
placeholder="Street address"
/>
<input
{...register("shipping.city", { required: "City is required" })}
placeholder="City"
/>
<input
{...register("shipping.zip", {
required: "ZIP code is required",
// ✅ US ZIP: 5 digits, or ZIP+4 format
pattern: {
value: /^\d{5}(-\d{4})?$/,
message: "Enter a valid ZIP code",
},
})}
placeholder="ZIP code"
/>
{errors.shipping?.zip && (
<p role="alert">{errors.shipping.zip.message}</p>
)}
<button type="submit">Place Order</button>
</form>
);
}
Using setError for server-side validation requires the same dot-notation path discipline:
const onSubmit: SubmitHandler<OrderForm> = async (data) => {
const result = await placeOrder(data);
if (result.error === "zip_invalid") {
// ✅ Path matches the registered field name exactly
setError("shipping.zip", {
type: "server",
message: "This ZIP code isn't in our delivery area",
});
}
};
This is explored in depth in the official react-hook-form errors docs. The GitHub discussions are also a solid resource for edge cases with deeply nested schemas.
React Hook Form vs. Other Form Libraries
Choosing the wrong library causes a different class of errors entirely. Here's how the three most common options compare for TypeScript projects:
| Feature | React Hook Form v7 | Formik | Native HTML + React state |
|---|---|---|---|
| Bundle size | ~9 KB gzipped | ~15 KB gzipped | 0 KB |
| Re-renders on change | Near-zero (uncontrolled) | Many (controlled) | Depends on implementation |
| TypeScript support | Excellent (generics on useForm) | Good (with Yup schema) | Manual |
| Validation | Built-in + Yup/Zod via resolver | Yup, Zod, custom | Manual |
| Controller for external libs | <Controller> component | <Field> component | Wrap in onChange handler |
| Array fields | useFieldArray | FieldArray | Manual state array |
| Form reset | reset() with defaultValues | resetForm() | setState({}) |
| Error handling | formState.errors object | errors + touched | Local state |
| Async validation | validate function | validate function | Manual async |
| DevTools | RHF DevTools | No official devtools | Browser devtools |
| Learning curve | Low–medium (ref-based mental model) | Medium | Low |
When to choose React Hook Form: performance-sensitive forms, TypeScript-first codebases, large forms with many fields, or when integrating with component libraries like shadcn/ui.
When to choose Formik: teams already familiar with it, projects using Yup schemas extensively, or forms that need the touched state management Formik provides out of the box.
When to use native state: simple forms with 2–3 fields where a library is overkill, or when the form state needs to live in a parent component for reasons unrelated to form management.
When AI-Generated Form Code Breaks
AI coding assistants — Cursor, GitHub Copilot, Claude — are genuinely useful for scaffolding react-hook-form boilerplate. But they introduce specific, predictable errors that show up after the initial scaffold is done.
The four patterns AI gets wrong most often:
1. Missing handleSubmit wrapper. AI often generates <form onSubmit={onSubmit}> rather than <form onSubmit={handleSubmit(onSubmit)}>. The code looks right, it runs without errors, but validation never triggers. This is the error from Fix 1 above, and it's the most common AI-generated react hook form bug.
2. No generic on useForm. AI scaffolds const { register } = useForm() without a type parameter. Everything works until you try to access errors in TypeScript — then you get a cascade of type errors that look unrelated to the missing generic.
3. Stale v6 patterns in v7 code. React Hook Form v7 made breaking changes: errors moved from the useForm return into formState, register now returns an object to spread rather than a ref callback, and Controller changed its render prop signature. AI models trained on older code (or Stack Overflow answers from before 2021) generate v6 patterns that silently fail in v7.
4. Controller without prop mapping. As covered in Fix 3, AI will spread {...field} onto components that need checked or onValueChange. The code compiles, the component renders, but the form state never updates.
The fix for all of these isn't complicated — it's knowing the patterns. If you're inheriting AI-generated form code that's misbehaving, run through the five fixes above in order. The culprit is almost always one of them.
React Hook Form has excellent official documentation and the Stack Overflow tag is active. For v6-to-v7 migration specifically, the official migration guide lists every breaking change. The MDN forms reference is useful when the bug turns out to be browser form behavior rather than library behavior.
If your app uses shadcn/ui with react-hook-form — a very common pairing — style issues sometimes get confused with logic issues. The Tailwind classes not applying shadcn guide covers the most common reasons a shadcn component looks wrong in a way that has nothing to do with form validation.
For prop-passing bugs that surface when form data moves between components, Next.js prop className mismatch covers the related class of issues.
Conclusion
React Hook Form is reliable when wired correctly — and nearly all the errors developers hit come back to a handful of patterns: missing handleSubmit, no TypeScript generic, Controller with unmapped props, reset() called at the wrong time, or bracket notation instead of dot notation for nested fields.
The five fixes in this guide cover the errors that account for the vast majority of react hook form issues in production codebases. The before/after examples are written to be copy-paste starting points, not abstract explanations.
If you've worked through all five and still have a form that won't behave — especially if it involves a complex schema, a custom UI library integration, or unexpected interactions with Next.js server components — this is the kind of problem that's faster to solve in a 30-minute screen share than in hours of solo debugging. The FlowQL team has seen every variant of react hook form breakage. Book a session and walk through your specific form — we'll find the issue and explain exactly why it's happening.
Still blocked?
This fix didn't work for your setup? Get a senior engineer on your screen in 30 minutes — fixed or refunded.
Reserve My Spot →Get a senior engineer on your screen.
30-minute live screen-share sessions with a vetted senior dev. Fixed or fully refunded — no questions asked.
No spam. Just a heads-up when sessions open.
Related Articles
TypeScript Enums: When to Use Them (and When Not To)
A practical guide to typescript enum patterns — numeric, string, and const enums — plus when to replace them with union types. With working code examples.
TypeScript vs JavaScript: Which Should You Use?
TypeScript vs JavaScript: understand the real differences, performance tradeoffs, and when to use each — so you can make the right call for your project.
Fix: params are undefined in Next.js App Router Server Component
params undefined in your Next.js server component? Learn how to handle the async breaking change in Next.js 15 and properly await your dynamic route data.