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
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