All articles
Web DevelopmentSeptember 2, 2026 6 min read

The React 19 useActionState Migration That Broke Our Forms

We swapped useFormState for useActionState across a mid-sized Next.js app. Here's what silently broke, what the docs don't tell you, and the patterns we ended up standardising on.

The React 19 useActionState Migration That Broke Our Forms

The rename from useFormState to useActionState looked like a five-minute find-and-replace. It wasn't. Two weeks after the migration we were still finding forms that submitted twice, pending states that lied, and TypeScript inference that quietly gave up.

This is what actually happened, what the React 19 docs gloss over, and the patterns we now use on every new form.

Why the rename matters more than it looks

On paper, useActionState is just useFormState with a third return value: isPending. In practice the semantics shifted enough that a naive rename introduced regressions we didn't catch until QA.

The old hook lived in react-dom and was explicitly a form-only helper. The new one lives in react and works with any async action, form or not. That reframing changed how the React team thinks about the action lifecycle — specifically around transitions, pending state, and what happens when a user clicks submit twice.

If you're moving a codebase from Next.js 14 to 15 with the React 19 upgrade, the compiler won't warn you about most of what follows. The types compile. The forms render. The bugs show up in production.

The import that trips everyone

// Before (React 18 / Next.js 14)
import { useFormState, useFormStatus } from 'react-dom';

// After (React 19 / Next.js 15)
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom'; // still lives here

useFormStatus stayed in react-dom because it's genuinely form-scoped. useActionState moved because it isn't. If you have a shared hooks/index.ts re-exporting both, split it now — otherwise Server Components will try to pull react-dom into places it doesn't belong.

The pending state that lies

The headline feature of useActionState is the built-in pending boolean. It replaces this pattern:

// Old
function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>Save</button>;
}

With this one:

// New
const [state, formAction, isPending] = useActionState(saveProfile, initialState);

return (
  <form action={formAction}>
    <button disabled={isPending}>Save</button>
  </form>
);

Cleaner. Except isPending from useActionState and pending from useFormStatus are not the same value in every case.

useFormStatus reads from the nearest form ancestor. isPending from useActionState reflects the transition wrapping the action call. When you dispatch the action programmatically — say, from a keyboard shortcut or an autosave — useFormStatus returns false because there's no form submission happening. isPending returns true because the transition is live.

We had an autosave button that read useFormStatus inside a child component. After migration it never disabled itself. The parent's isPending was correct; the child had no idea.

Rule we settled on: if a button lives inside <form action={formAction}> and is triggered by the form, use useFormStatus. If anything else can invoke the action, thread isPending down as a prop.

TypeScript inference gives up quietly

The useActionState signature is more permissive than its predecessor, and TypeScript inference reflects that. Here's the shape we ended up with after fighting it:

type FormState =
  | { status: 'idle' }
  | { status: 'success'; message: string }
  | { status: 'error'; errors: Record<string, string[]> };

async function saveProfile(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const parsed = ProfileSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
  });

  if (!parsed.success) {
    return {
      status: 'error',
      errors: parsed.error.flatten().fieldErrors,
    };
  }

  await db.profile.update(parsed.data);
  return { status: 'success', message: 'Saved' };
}

The first argument type — prevState — is what the compiler uses to infer the state generic. If you write prevState: any or omit the annotation, useActionState widens to unknown and every read at the call site needs a narrow. We saw one team ship state?.errors?.email?.[0] sprinkled through JSX because nobody wanted to fix the root type.

The fix is boring: annotate prevState explicitly and export the action's return type. Then the hook consumer gets full narrowing for free.

The initial state pitfall

const [state, formAction, isPending] = useActionState<FormState, FormData>(
  saveProfile,
  { status: 'idle' }
);

If initialState doesn't satisfy every branch of your discriminated union, TypeScript infers the state as the narrow initial shape. Every subsequent access to state.errors then errors, and juniors reach for as any. Always cast the initial value to your full union type, or pass the generics explicitly like above.

Double submissions and the transition boundary

The old useFormState implicitly wrapped its dispatch in a transition, but pending state came from useFormStatus reading the DOM. The new hook wraps the action in a transition and owns the pending flag. That's usually fine — until you have a button outside the form:

<form action={formAction}>
  {/* fields */}
</form>
<button onClick={() => formAction(new FormData(formRef.current!))}>
  Save from toolbar
</button>

Calling formAction directly from a click handler bypasses React's form-submission event handling. In our app, this meant the toolbar button could fire while a form submission was already in flight, because isPending hadn't updated yet on the click handler's snapshot.

The fix is to gate the manual call:

<button
  disabled={isPending}
  onClick={() => {
    if (isPending) return;
    startTransition(() => {
      formAction(new FormData(formRef.current!));
    });
  }}
>
  Save from toolbar
</button>

Wrapping in startTransition makes the intent explicit and, more importantly, ensures the pending flag is set synchronously in the same tick.

Redirects eat your success state

This one cost us an afternoon. A common pattern after a successful mutation:

async function createInvoice(prev: FormState, formData: FormData) {
  const invoice = await db.invoice.create({ /* ... */ });
  redirect(`/invoices/${invoice.id}`);
}

redirect from next/navigation throws a special error that Next.js catches. That means your action never returns, so state never updates to a success shape. If you were showing a toast based on state.status === 'success', it never fires — the user just navigates away, which is often fine.

But if the redirect is conditional and you want a toast on the same page, don't use redirect. Return a state that includes the target URL and let the client decide:

return { status: 'success', redirectTo: `/invoices/${invoice.id}` };

Then in the component, run a useEffect on state to call router.push. It's more code, but the state machine stays honest and toasts fire before navigation.

Progressive enhancement still works — mostly

One of the reasons to use useActionState over a plain useState + fetch is that forms keep working without JavaScript. That's true, with an asterisk: the permalink argument (third parameter to useActionState) is what makes no-JS submissions land on the right URL with the right state.

const [state, formAction, isPending] = useActionState(
  saveProfile,
  { status: 'idle' },
  '/settings/profile' // permalink for no-JS fallback
);

Without the permalink, a form submitted before hydration will POST to the current URL and the response state won't rehydrate into the component. If you care about the pre-hydration window — and on slow mobile, you should — set it.

Accessibility notes we wish we'd baked in earlier

A pending button that's just disabled is a screen-reader dead end. Users hear nothing between click and completion. Announce the state:

<button disabled={isPending} aria-busy={isPending}>
  {isPending ? 'Saving…' : 'Save'}
</button>
<p role="status" aria-live="polite" className="sr-only">
  {state.status === 'success' && state.message}
  {state.status === 'error' && 'Please fix the errors below'}
</p>

aria-live="polite" on a status region gets you free announcements when state changes. It's four lines and it turns a broken form UX into an accessible one.

Where we'd start

If you're about to migrate: don't do it as a sweep. Pick one form — ideally one with validation, redirects, and a pending button — and port it end to end. Write down every behaviour that changed. Then apply the pattern to the rest.

Our checklist now: annotate prevState, cast initial state, decide per-form whether useFormStatus or the hook's isPending owns the button, avoid redirect when you need a success toast, and set the permalink for anything that renders above the fold. It's not glamorous work, but it's the difference between a form that feels tight and one that quietly drops submissions in production.

If you want a second pair of eyes on a React 19 migration or an audit of your server-action patterns, our web development team does this kind of work regularly.

#React 19#Next.js#Server Actions#TypeScript#Forms

Want a team like ours?

72Technologies builds production software for the kind of teams who actually read this blog.

Start a project