
Zod 4 React Hook Form Errors Not Showing? Here's Why
Skilldham
Engineering deep-dives for developers who want real understanding.
Last updated: August 2026
TL;DR
Zod 4 react hook form errors go missing in three ways. A thrown ZodError never reaches formState.errors. A refine() error on a discriminated union branch gets dropped. A React Native form just never submits, with no error at all. All three come from one seam: Zod 4 and @hookform/resolvers. Wrap your resolver in a safe fallback. Pin known-good versions to stop the fight.
You wire up zodResolver(schema). You test a bad email. Nothing shows up under the input.
You open the console. There it is: Uncaught (in promise) ZodError.
Not in formState.errors. Not anywhere near your UI. Just a red wall of text in the console. The form looks like it validated fine.
You check your schema. It looks correct. You check the RHF docs. The setup matches the example exactly.
Here's what's actually going on. It's not something either library's docs describe. It's a seam between the two of them.
Zod 4 Errors Get Thrown Instead of Captured
This is the most reported failure. Two GitHub issues describe the same symptom. They are five months apart. The schemas differ. The developers differ.
The Uncaught ZodError
Here's a minimal setup that reproduces it. This is close to the actual repro from the RHF issue tracker.
javascript
// Wrong: standard v3-era setup, breaks on some zod 4 / RHF combos
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const signupSchema = z.object({
email: z.email("Please enter a valid email address."),
password: z.string().min(6, "Password must be at least 6 characters."),
name: z.string().min(1, "Please enter your name."),
});
const SignupForm = () => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(signupSchema),
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email")} />
{errors.email?.message && <p>{errors.email.message}</p>}
<button type="submit">Sign up</button>
</form>
);
};Submit this with a bad email on the affected version combo. errors.email stays undefined. The console shows Uncaught (in promise) ZodError. The real validation issues sit buried inside it. Your UI never sees them.

Where RHF's Try/Catch Doesn't Reach
The RHF maintainers closed the first report. No fix followed. A second developer reopened it months later. They used a newer version. They used a different schema. The console error was the same.
One trigger is separate from this bug. It causes the same symptom, though. RHF checks your defaultValues against the schema on mount. Say a default already fails a min() check. Zod throws before submit ever runs.
A second trigger is harder to pin down. On some version pairs, the resolver's promise chain rejects. It does not resolve to a normal error. Your try/catch never sees it. The failure happens inside RHF, not your code.
You do not need the exact internal cause to stop the crash. You need a resolver that cannot throw past your form.
Discriminated Union Refine Errors Get Silently Dropped
This one is worse than a thrown error. Nothing crashes. Nothing logs. The form looks valid when it isn't. That's the same trap as stale state that looks fine.
Reproducing the Dropped Refine Error
This is adapted from a confirmed issue on the @hookform/resolvers repo. It was filed against zod@4.1.11 and @hookform/resolvers@5.2.2.
javascript
// Wrong: refine on each union branch, one branch's error gets lost
const rangeSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("low"),
low: z.number(),
}).refine((val) => val.low >= 0, {
message: "Low must be zero or higher",
path: ["low"],
}),
z.object({
type: z.literal("high"),
low: z.number(),
high: z.number(),
}).refine((val) => val.high > val.low, {
message: "High must be greater than low",
path: ["high"],
}),
]);Enter a value that fails the check on the "low" branch. One error shows on screen. A real failure on "high" never reaches formState.errors. The form looks partly valid. It is not.
Why Only the First Branch Wins
The resolver walks the union. It finds the first branch's refine issue. It maps that one to formState.errors. It does not keep collecting issues from other paths. A flat object schema would keep going. This one stops after the first branch.
The practical fix: stop putting refine() on individual union branches. Move the cross-field check to a superRefine() on the whole union instead. Run it after the discriminant has already narrowed the type.
javascript
// Correct: superRefine on the full union, runs after narrowing
const rangeSchema = z
.discriminatedUnion("type", [
z.object({ type: z.literal("low"), low: z.number() }),
z.object({ type: z.literal("high"), low: z.number(), high: z.number() }),
])
.superRefine((val, ctx) => {
if (val.type === "low" && val.low < 0) {
ctx.addIssue({
code: "custom",
message: "Low must be zero or higher",
path: ["low"],
});
}
if (val.type === "high" && val.high <= val.low) {
ctx.addIssue({
code: "custom",
message: "High must be greater than low",
path: ["high"],
});
}
});superRefine runs once on the parent, after a branch is picked. Every ctx.addIssue call gets collected in the same pass. Nothing gets dropped on the way to formState.errors.
React Native Forms Silently Fail to Submit
This is the quietest failure of the three. No thrown error. No dropped field. The submit handler just never fires. This is the same dead end as React Native push notifications that go silent.
The Duplicate Zod Copy Problem
A confirmed issue on the core Zod repo shows this symptom. Versions: zod@^4.0.10, react-hook-form@^7.61.1, @hookform/resolvers@^5.2.0, on React Native. Tapping submit does nothing. No log. No error. No network call.
Wrap the resolver in a manual try/catch. The real message shows: Invalid element at key "firstName": expected a Zod schema. Zod's own code does not know a schema it just built.
Here's why. Zod 4 uses a strict check for a "real" schema. Metro's bundler can ship two copies of Zod in one app. One copy built your schema. The other lives inside @hookform/resolvers, from zod/v4/core. Each copy has its own class. The check fails across the two copies. Both are still "Zod."
A related Hermes report shows the same shape. Schema setup itself throws under Hermes. Hermes treats class instances differently than V8. That gap breaks the check. It's the same issue behind other React Native runtime crashes.
Confirming You Have Two Zods
Run this from your project root, before you touch any code:
bash
npm ls zodCheck the list. Does zod show up more than once, at different paths? That's a duplicate. It's the likely cause, not your schema. It's the same habit worth building after any npm supply-chain scare.
bash
# Correct: force a single resolved copy
npm dedupeIf dedupe does not fix it, pin zod, react-hook-form, and @hookform/resolvers together. Use one known-good combo, from the table below. Delete your lockfile first. Then reinstall.
The Safe Resolver Wrapper (Copy-Paste Fix)
One pattern catches both cases: the thrown error and the silent React Native fail. It never lets a resolver exception escape past your form.
The Wrapper Function
javascript
// Correct: safe-resolver wrapper - catches thrown errors from the resolver itself
import { zodResolver } from "@hookform/resolvers/zod";
function safeZodResolver(schema) {
const resolver = zodResolver(schema);
return async (values, context, options) => {
try {
return await resolver(values, context, options);
} catch (error) {
console.error("Resolver threw instead of returning errors:", error);
return {
values: {},
errors: {
root: {
type: "resolver_exception",
message: "Something went wrong validating this form. Please try again.",
},
},
};
}
};
}This does not fix the discriminated-union drop. That needs the superRefine change above. It stops one thing: a blank, broken form with no message at all.
Wiring It Into useForm
javascript
// Correct: use the wrapper exactly where you'd use zodResolver directly
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: safeZodResolver(signupSchema),
});errors.root.message gives you a fallback banner. Show it when the resolver itself failed. That beats a silent console error. It also beats a form that looks broken with no explanation.
Version Pin Table: What Actually Works Together
This is a moving target. Three packages ship on their own. Treat this as a snapshot, not a fixed answer. The same rule applies to pinning Prisma 7's driver versions together.
Combos That Throw or Drop Errors
zodreact-hook-form@hookform/resolversConfirmed issue4.0.107.61.15.2.0RN "expected a Zod schema" - zod#4989(v4, unspecified)7.56.3(unspecified)Uncaught ZodError - RHF#128164.1.117.63.05.2.2Discriminated union refine dropped - resolvers#817
The Combo We'd Reach For Right Now
@hookform/resolvers shipped a fix in 5.5.7 for a related bug. That release fixed dropped errors on some field names. The maintainers still patch this seam. It's the same fast-moving problem behind TypeScript 7 breaking ESLint.
PackageKnown-good version (at time of writing)zod4.4.x or laterreact-hook-form7.80.x or later@hookform/resolvers5.5.7 or later (5.9.x is current)
Before you upgrade any one package, check the other two changelogs first. A resolver bump alone has broken working forms before.
Why This Keeps Happening
None of this is a bug in Zod. None of it is a bug in React Hook Form. It's friction at one point. That's where one library hands data to another.
Two Zod Copies, One Instanceof Check
Zod 4 uses a strict check for a valid schema. That's a fair design choice on its own. It breaks the moment a bundler ships two copies of the library. Metro does this more than webpack. It's common in monorepos and Expo apps.
The Resolver Package Is Still Patching This
@hookform/resolvers is a thin bridge. A separate team maintains it. Every minor release on either side can break the bridge. That's why this isn't a one-time move from Zod 3 to Zod 4. It's an ongoing seam. It reopens with each new minor release. Three packages, each versioned on its own, depend on each other's shape.
Key Takeaways
Zod 4 react hook form errors go missing in three ways: thrown instead of captured, dropped on a union branch, or a silent no-op on React Native.
A thrown ZodError in the console with nothing in formState.errors usually means the resolver's promise chain rejected.
refine() on individual union branches can drop a second field's error. Move the check to superRefine() on the parent schema instead.
A React Native form that submits nothing, and logs nothing, is often two copies of Zod bundled into one app.
Run npm ls zod before you debug your schema. A duplicate install explains more of these bugs than a schema mistake does.
A safe-resolver wrapper that catches exceptions from zodResolver stops the crash cases from reaching users as a blank form.
Pin all three packages together. Re-check the pin after any single-package upgrade. This is an ongoing seam, not a one-time fix.
FAQ
Does this still happen on the latest Zod and React Hook Form versions?
The exact reports here were filed against specific versions in mid to late 2025. @hookform/resolvers has patched related bugs since. The latest patch was 5.5.7. Treat any single combo as temporary. Re-check the changelogs before you upgrade.
Is this a Zod bug or a React Hook Form bug?
Neither, directly. It's a mismatch at the bridge layer: @hookform/resolvers. That bridge has to track shape changes on both sides, on its own. So the fix is usually a wrapper or a version pin, not a patch to either core library.
Why does my schema work fine outside of React Hook Form?
Because schema.safeParse(data) called directly works just as documented. The failure sits in how the resolver bridges Zod's result into formState.errors. It's not your schema.
Can I just downgrade to Zod 3 to avoid all of this?
It works for some cases. That includes the React Native "expected a Zod schema" bug, where switching to zod/v3 has fixed it for reporters. It's a real short-term option if you're blocked. It does mean missing Zod 4's error format and the coming v3 deprecation.
Does the safe resolver wrapper fix the discriminated union bug too?
No. The wrapper only catches exceptions the resolver throws. The union drop is a mapping gap, not a thrown error. It needs the superRefine fix covered above.
How do I know if I have two copies of Zod installed?
Run npm ls zod from your project root. Does it list Zod at more than one path? A dependency is bundling its own copy. Try npm dedupe first. Pin versions and reinstall if that doesn't fix it.
Should I use mode onSubmit or onChange to avoid this?
The validation mode does not stop any of these three bugs. All three trace to the resolver layer. That layer runs no matter when validation triggers.
Is this specific to TypeScript, or does it happen in plain JavaScript too?
It happens in both. These are runtime bugs in how the resolver reads Zod's output. They are not TypeScript type errors. Dropping TypeScript will not fix them. For the type-level errors Zod and TypeScript throw at build time, see common TypeScript build error fixes.
Zod 4 react hook form errors: get the fix shipped
The pattern across all three bugs is the same. Something in the Zod-to-RHF bridge does not survive the handoff. A thrown error, a dropped field, a form that goes quiet. Each one looks different until you see the seam underneath.
Wrap your resolver defensively. Fix unions with superRefine. Pin your three package versions together. Recheck that pin with every minor release.
If a new form starts failing after an upgrade, check the version pin table first. Check the schema last. Join the SkillDham newsletter to get notified when the pin table updates.