Skip to main content
ASoc
Tutorial

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.

The ASoc Team7 min read

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 APIThe RHF feature it overlapsStill need RHF for it?
<form action={fn}>handleSubmit() wiring, preventDefault, manual fetchNo — the browser posts FormData even before hydration
useActionStateformState.isSubmitSuccessful, server-error plumbingNo
pending from useActionStateformState.isSubmittingNo
useFormStatusisSubmitting read from a nested childNo — it reads the enclosing form
useOptimisticnothing in RHFNew capability, not a replacement
ref as a prop (no forwardRef)register()'s ref forwarding into your own inputsSimplifies your wrappers, not RHF itself
—register() uncontrolled inputs, per-field re-render isolationYes
—resolver-based schema validation on blur/changeYes
—watch/useWatch, useFieldArray, cross-field rulesYes

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:

FileLinesJob
src/components/molecules/AuthCard.tsx112Shared chrome for the four auth forms
src/components/molecules/RedemptionPicker.tsx129Entitlement redemption, with a confirm step
src/components/molecules/SettingsForm.tsx127Account settings cards
src/components/molecules/ContactForm.tsx75Contact, with a honeypot field
src/components/molecules/NewsletterForm.tsx61Footer subscribe + conversion event
src/components/atoms/FormStatus.tsx55The announced result message
src/lib/validation.ts28The 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

  1. npm ls react-hook-form — note the installed major, then read its peerDependencies. An RHF too old for React 19 fails as an ERESOLVE peer conflict at install time, not as a runtime bug.
  2. Delete forwardRef wrappers around your inputs. In React 19 ref is an ordinary prop, so a wrapper that exists only to forward one has no reason to exist. This repo has 0 forwardRef calls.
  3. Decide per form, not per app. A 2–4 field form that submits once is a useActionState form; a wizard with field arrays stays on RHF. Mixing them in one codebase is fine — they do not share state.
  4. If you keep RHF inside a Server Actions app, submit through the action rather than a fetch in onSubmit, or you give up the progressive-enhancement half for nothing.
  5. Re-check your result messages against the live-region rule above.

Troubleshooting

SymptomCauseFix
ERESOLVE peer conflict installing on React 19RHF major predates React 19's peer rangeUpgrade RHF before React, or install and read the printed peer range
useActionState is not a functionImported from react-dom, or React is still 18It is exported from react in 19; useFormStatus is the react-dom one
Server Action result never updates the UIonSubmit={handleSubmit(...)} is still interceptingPass the action to <form action={...}> instead
Screen reader silent on validation errorsThe aria-live node mounts with its first messageRender the region always, as FormStatus does
Values update but a dependent UI does notReading a value with a non-subscribing APISubscribe to it (useWatch in RHF; in plain React, derive it from state)
Submit works with JS disabled in dev, fails in prodA client-only handler, not a form actionA Server Action posts FormData with no JS; keep the logic there
pending flickers back before navigationuseActionState resolved, the route change has notNavigate 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.

Keep reading

Tutorial8 min read

React JSX: The Rules That Only Break the Production Build

JSX compiles to function calls, not HTML. Where the two diverge, why a lowercase SVG tag survives dev and fails next build, and the key rule behind phantom state bugs.

Read more
Tutorial10 min read

React Landing Pages: Half the HTML Isn't the Page

This storefront's home page prerenders to 436 KB, and 52.1% of that is a serialized copy of the render, not the page. What actually reaches a crawler before any JavaScript runs.

Read more