The Problem
I ran into this on a client project last week, a Next.js 16.2 admin dashboard upgraded from React 19.1 to 19.2 to pick up the View Transition stability fixes. Right after the upgrade, the team flagged a regression: every form submit button stayed disabled after the server action returned. The action ran fine, the data saved, the page even revalidated, but useFormStatus().pending never flipped back to false. The user had to refresh to submit again.
If you upgraded to React 19.2 and your submit button is now stuck in a pending state after a Server Action, or useFormStatus returns pending: true forever after a redirect, this is the bug pattern. It hits hardest in App Router setups that mix useActionState, redirect(), and nested Client Components.
Why It Happens
useFormStatus reads its state from the closest ancestor <form> element. React tracks pending state via a transition wrapped around the action invocation. The hook flips pending: true when the transition starts and pending: false when the transition's promise settles.
Three things break the settle:
redirect()thrown inside the server action before it returns. Next.js implementsredirect()by throwing a specialNEXT_REDIRECTerror that bubbles up to the framework. React 19.2 tightened error handling on transitions, so an unsettled action that ends in a thrown redirect leaves the transition in apendingstate on the client until the new page hydrates. If the new page has the same form mounted (App Router on the same route segment), the client reuses the form instance andpendingis stilltrue.useFormStatuscalled from outside the form. I see this constantly. Devs put<SubmitButton />next to the form, not inside it. The hook silently returns{ pending: false, data: null, method: null, action: null }initially, then never updates because there is no form ancestor to subscribe to. After 19.2's reconciliation changes, this regression shows up where it used to "work by accident".- Throwing inside the action without a return value. If your server action throws (validation error, network failure,
notFound()), and you do not catch it, React 19.2 marks the transition as errored but does not always settle the pending flag if the form re-renders before the error boundary catches. The button stays disabled.
The 19.1 version was more lenient. 19.2 made transition cleanup deterministic, which surfaced these existing bugs.
The Fix
Step 1: Move useFormStatus inside the form. This is the most common cause. Make <SubmitButton /> a Client Component rendered as a child of the form.
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending} aria-disabled={pending}>
{pending ? 'Saving…' : children}
</button>
);
}
import { SubmitButton } from './submit-button';
import { saveProfile } from './actions';
export function ProfileForm() {
return (
<form action={saveProfile}>
<input name="name" required />
<SubmitButton>Save</SubmitButton>
</form>
);
}
If SubmitButton is rendered anywhere else in the tree, pending will not update. There is no warning for this, the hook just returns its idle state.
Step 2: Stop throwing redirect() from inside the action — return it from useActionState. For App Router, useActionState is the right shape because it owns the pending lifecycle and integrates with React 19.2's transition cleanup correctly.
'use client';
import { useActionState } from 'react';
import { saveProfile } from './actions';
export function ProfileForm() {
const [state, formAction, isPending] = useActionState(saveProfile, {
error: null,
});
return (
<form action={formAction}>
<input name="name" required />
{state.error && <p role="alert">{state.error}</p>}
<button type="submit" disabled={isPending}>
{isPending ? 'Saving…' : 'Save'}
</button>
</form>
);
}
'use server';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
export async function saveProfile(prevState: { error: string | null }, formData: FormData) {
const name = formData.get('name');
if (typeof name !== 'string' || name.trim().length === 0) {
return { error: 'Name is required' };
}
await db.profile.update({ where: { id: 1 }, data: { name } });
revalidatePath('/profile');
redirect('/profile/saved');
}
useActionState's third tuple value isPending is the right source of truth for App Router forms. It settles cleanly across the redirect because React tracks the pending state on the action ref, not via DOM ancestor lookup.
Step 3: Always return a result, never let an action fall through. If validation fails, return an error object. If the work succeeds and you redirect, the redirect throws and that is fine because useActionState catches it. The rule: every code path either returns or redirects. No silent throws.
Step 4: Verify in DevTools. Open React DevTools, find the form's transition state, and confirm isPending toggles. If you still see it stuck, check the React canary changelog for useFormStatus updates. The 19.2.1 patch picks up an additional cleanup case for nested transitions.
The Lesson
In React 19.2, useFormStatus only works as a child of a <form> element, and useActionState is the cleaner pattern when you need the pending flag at the form level instead of inside the button. Throwing redirects without useActionState will leave the transition unsettled in 19.2 even though it worked in 19.1.
If your team is hitting React 19.2 regressions across an upgrade, I do this kind of upgrade work as part of my services. For a related Server Action pitfall I wrote up recently, see Next.js 16 Server Actions invalid action error.