Skip to main content

October 5, 2026

RHF field errors hide under React Compiler

I fixed register and forgot-password where RHF stored errors but the UI never painted them until fields used useFormState.

Sander Korf4 min read
reacttypescriptnextjs

A fashion storefront runs account flows on Next.js: register, forgot password, and login. Each screen uses React Hook Form with a shared shadcn-style Form primitive (FormField, FormControl, FormMessage). Shoppers submit empty or invalid fields and expect red inline messages plus aria-invalid on the control. That feedback is the contract. Without it, the form looks fine while validation silently fails in the background.

I hit this on register and forgot-password in production (NL locale). Submit with a blank email stored errors inside React Hook Form. DevTools showed the field error. The UI did not. aria-invalid stayed false and FormMessage never mounted. Login on the same design system still painted errors, which sent me chasing the wrong pages first.

Form host means the page component that calls useForm and wraps children in FormProvider. FormState snapshot is the formState object copied into React context the last time that host rendered. Field subscription is useFormState({ name }) so a field re-renders when that field's slice changes. React Compiler is the build-time memoization pass that can skip re-rendering a host that looks pure after submit.

In the demo below, leave Context snapshot selected, then press Submit empty. The email field should keep aria-invalid="false", and the red message should not appear even though the footnote says RHF stored errors. Switch to Field subscription and submit again: the message and aria-invalid="true" should show while the memoized provider shell still does not re-render. Login on the real storefront still painted errors because that form host re-rendered after submit and refreshed the snapshot; register and forgot-password did not.

Form field errors vs form host renders

Mirrors shadcn-style useFormField: context formState snapshot vs per-field useFormState, with a memo host that never re-renders (React Compiler-shaped).

FormMessage slot (empty until invalid)

RHF stored errors after submit attempts: no (attempts: 0)

Context snapshot (stale)Frozen form host (register-like)

After submit with context snapshot wiring, aria-invalid stays false and the message stays empty even though RHF stored errors (see footnote).

Why validation looked fine in isolation

Zod and the resolver were not the problem. I could log form.formState.errors after submit and see the email rule fire. Playwright-style checks that only read RHF state would pass. Only paint failed. Login still worked because that form host re-rendered after every submit attempt, so the formState snapshot in context caught up before fields read it.

That split made the bug feel like a React 19 quirk or an RHF version regression. I briefly considered forcing remount keys on each route or rewiring register and forgot-password without the shared primitive. None of that explained why one auth screen worked and two did not while they shared the same useFormField helper.

What the shared helper actually read

The classic shadcn pattern pulls formState from context and asks getFieldState to interpret it:

const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)

Spreading the whole form object into FormProvider copies formState at the provider's last render. React Compiler started skipping re-renders on register and forgot-password hosts after submit. Internal RHF state updated; the provider did not. Fields that only read the snapshot never saw invalid flip true, so FormMessage had nothing to render and assistive tech never got aria-invalid.

Per-field subscription instead of context snapshot

The fix keeps getFieldState but subscribes per field:

const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)

Each control listens to its own slice. The form host can stay frozen and errors still paint. I added a unit test with a memoized shell that never re-renders the provider, mirroring the compiler case:

const FrozenForm = memo(function FrozenForm({ form }) {
  return <Form {...form}>...</Form>
}, () => true)

After an invalid submit, assert aria-invalid="true" and the message text. Without field subscription, that test fails exactly the way production did.

When to apply this pattern

Reach for field subscription when shared FormMessage or aria-invalid helpers read context formState without subscribing, especially under React Compiler or memoized parents that freeze the form host. Skip the refactor when every host already re-renders on submit and you have proof fields see fresh snapshots. Login was my canary for that second case.

If you ship React Compiler on auth flows, add the frozen-provider test once. It is cheaper than another silent NL regression where validation exists only in DevTools.


Happy coding! Sander