React Hook Form on React 19: What the Upgrade Actually Changes
React 19 absorbed the submission lifecycle, not the validation engine. The API-by-API split, five forms with no form library, and the a11y trap.
React Hook Form still works on React 19 — it is a client library, and nothing in React 19 removes it. What React 19 removes is the reason half of it existed: <form action={fn}>, useActionState, useFormStatus and useOptimistic ship submission state, pending state and progressive enhancement in the framework. This codebase runs React 19.2.4 with five real forms and no form library at all.
So the upgrade question is not "does RHF break on 19" — it is which half of RHF you are still paying for. The React Hook Form post argues the general case for this codebase's choice; this one is about the upgrade itself: what React 19 took over, what it did not, and what to check before you bump.
What React 19 took over, API by API
| React 19 API | The RHF feature it overlaps | Still need RHF for it? |
|---|---|---|
<form action={fn}> | handleSubmit() wiring, preventDefault, manual fetch | No — the browser posts FormData even before hydration |
useActionState | formState.isSubmitSuccessful, server-error plumbing | No |
pending from useActionState | formState.isSubmitting | No |
useFormStatus | isSubmitting read from a nested child | No — it reads the enclosing form |
useOptimistic | nothing in RHF | New capability, not a replacement |
ref as a prop (no forwardRef) | register()'s ref forwarding into your own inputs | Simplifies your wrappers, not RHF itself |
| — | register() uncontrolled inputs, per-field re-render isolation | Yes |
| — | resolver-based schema validation on blur/change | Yes |
| — | watch/useWatch, useFieldArray, cross-field rules | Yes |
The split is clean: React 19 absorbed the submission lifecycle. It absorbed none of the per-keystroke validation and field-array machinery, which is what RHF was actually built for.
The five forms here, and what each line replaces
Every form on this storefront is the same two-part shape — a Server Action that validates, and a thin client wrapper that renders its result. src/components/molecules/AuthCard.tsx is the one four of them share:
// src/components/molecules/AuthCard.tsx
const [state, formAction, pending] = useActionState<AuthState, FormData>(
action,
null,
);
// ...
<form action={formAction} className="mt-6 flex flex-col gap-4">
{children}
<button type="submit" disabled={pending}>
{pending ? (pendingLabel ?? "Please wait…") : submitLabel}
</button>
<FormStatus state={state} />
</form>
Three lines carry what an RHF form spends a useForm() config, a resolver and a handleSubmit wrapper on: state is the last submission's result, formAction is the submit handler, pending is isSubmitting. Login, signup, forgot-password and reset-password all render this one molecule with a different action and different fields as children.
The whole client-side form layer across the site is 587 lines:
| File | Lines | Job |
|---|---|---|
src/components/molecules/AuthCard.tsx | 112 | Shared chrome for the four auth forms |
src/components/molecules/RedemptionPicker.tsx | 129 | Entitlement redemption, with a confirm step |
src/components/molecules/SettingsForm.tsx | 127 | Account settings cards |
src/components/molecules/ContactForm.tsx | 75 | Contact, with a honeypot field |
src/components/molecules/NewsletterForm.tsx | 61 | Footer subscribe + conversion event |
src/components/atoms/FormStatus.tsx | 55 | The announced result message |
src/lib/validation.ts | 28 | The shared regexes |
react-hook-form appears in none of them, and in none of the 15 runtime dependencies in package.json.
Validation did not move to the client — it moved to the action
The reason none of these forms needs a resolver is that the thing that varies per submission can only be answered by the server anyway:
// src/lib/actions/auth.ts
export async function signUpWithPassword(
_prev: AuthState,
formData: FormData,
): Promise<AuthState> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
const password = String(formData.get("password") ?? "");
const consent = formData.get("consent");
if (!consent) {
return { ok: false, message: "Please accept the Terms and Privacy Policy" };
}
if (!EMAIL_RE.test(email)) {
return { ok: false, message: "Please enter a valid email address." };
}
if (password.length < MIN_PASSWORD_LENGTH) {
return {
ok: false,
message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`,
};
}
// ...
}
There are 18 Server Actions across src/lib/actions/, and each one re-reads the raw FormData itself. That is not duplicated effort — it is the only validation that is not optional, because a client check is advice and a forged POST ignores advice. The server-side validation post covers the allowlist pattern that goes beyond "is this field present".
Keep RHF for the opposite case: a long form where a user should learn at field 3 that it conflicts with field 11, without a round trip per keystroke.
The React 19 defect this codebase actually shipped
The migration has one real trap, and it is not RHF's. All five forms originally rendered their result message like this:
{state && <p aria-live="polite">{state.message}</p>}
That announces nothing on most screen readers. A live region has to be in the accessibility tree before its contents change — the announcement is a mutation inside a region already under observation. Mounting the aria-live node together with its first message inserts a new subtree instead. The fix is src/components/atoms/FormStatus.tsx, which renders the monitored region unconditionally:
// src/components/atoms/FormStatus.tsx
<p aria-live="polite" className="sr-only">
{state?.message ?? ""}
</p>
{state && (
<p aria-hidden="true" className={state.ok ? okClassName : errorClassName}>
{state.message}
</p>
)}
Two nodes on purpose: the monitored one is sr-only (absolutely positioned, so it adds no gap to the four callers that lay fields out with flex flex-col gap-*), and the visible copy is aria-hidden so the message is announced once rather than read again in reading order.
This is worth naming in an upgrade post because useActionState makes it easy to hit. Its state is null until the first submit, so the natural {state && …} render is exactly the broken shape — and RHF's formState.errors leads to the same conditional. Whichever library you land on, the live region is yours to get right.
Before you bump React
npm ls react-hook-form— note the installed major, then read itspeerDependencies. An RHF too old for React 19 fails as anERESOLVEpeer conflict at install time, not as a runtime bug.- Delete
forwardRefwrappers around your inputs. In React 19refis an ordinary prop, so a wrapper that exists only to forward one has no reason to exist. This repo has 0forwardRefcalls. - Decide per form, not per app. A 2–4 field form that submits once is a
useActionStateform; a wizard with field arrays stays on RHF. Mixing them in one codebase is fine — they do not share state. - If you keep RHF inside a Server Actions app, submit through the action rather than a
fetchinonSubmit, or you give up the progressive-enhancement half for nothing. - Re-check your result messages against the live-region rule above.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
ERESOLVE peer conflict installing on React 19 | RHF major predates React 19's peer range | Upgrade RHF before React, or install and read the printed peer range |
useActionState is not a function | Imported from react-dom, or React is still 18 | It is exported from react in 19; useFormStatus is the react-dom one |
| Server Action result never updates the UI | onSubmit={handleSubmit(...)} is still intercepting | Pass the action to <form action={...}> instead |
| Screen reader silent on validation errors | The aria-live node mounts with its first message | Render the region always, as FormStatus does |
| Values update but a dependent UI does not | Reading a value with a non-subscribing API | Subscribe to it (useWatch in RHF; in plain React, derive it from state) |
| Submit works with JS disabled in dev, fails in prod | A client-only handler, not a form action | A Server Action posts FormData with no JS; keep the logic there |
pending flickers back before navigation | useActionState resolved, the route change has not | Navigate in an effect on state?.ok, as AuthCard does |
Frequently asked questions
Is React Hook Form deprecated by React 19?
No. It is a third-party library and React 19 does not replace register(), resolvers, useFieldArray or per-field subscriptions. It replaces the submission plumbing around them.
Can I use React Hook Form with Server Actions?
Yes. Give the <form> the action and let RHF own the client-side validation, so an unhydrated page still submits. Do not route the submit through your own fetch unless you have a reason to lose that.
Do I need useFormStatus if I already have useActionState?
Only in a child component. useActionState hands pending to the component that owns the form; useFormStatus lets a nested submit button read the enclosing form's state without prop-drilling.
What does React 19 not give me that RHF does? Per-field re-render isolation, schema validation before submit, field arrays, and cross-field rules evaluated on blur. On a long, branching form those are the whole job — that is where RHF still earns its place.
Templates in this post
ASoc Beacon (a mobile-device-management site), ASoc Beaker (a science-lab services site) and ASoc Blueprint (an app-development agency site) each ship a Next.js edition on React 19, so the form patterns above are the ones you would be editing.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
