The Problem
A signup form on a Next.js 16.2 client project started showing a permanently spinning submit button after a successful submission. The action ran, the user was redirected to the dashboard, and if they navigated back to the form the button was still locked in "Saving..." state. Refreshing the page fixed it. Submitting again did nothing because the disabled attribute on the button was still true.
The form was using useActionState from React 19.2 with a server action that calls redirect() on success:
'use client'
import { useActionState } from 'react'
import { signup } from './actions'
export function SignupForm() {
const [state, formAction, isPending] = useActionState(signup, { error: null })
return (
<form action={formAction}>
<input name="email" type="email" required />
<button disabled={isPending}>{isPending ? 'Saving...' : 'Sign up'}</button>
{state.error && <p>{state.error}</p>}
</form>
)
}
The server action:
'use server'
import { redirect } from 'next/navigation'
export async function signup(prev: { error: string | null }, formData: FormData) {
const email = formData.get('email')
await createUser(String(email))
redirect('/dashboard')
}
On every dev tools session I checked, isPending flipped to true on submit, never flipped back. The useActionState reducer fired once when the action started, never when it finished, because the action never finished in the React sense. It threw a redirect, the navigation happened, and the component re-rendered with isPending still pinned to its in-flight value.
Why It Happens
redirect() works by throwing a special internal NEXT_REDIRECT error that the Next.js framework catches at the route boundary and turns into a navigation. Inside the server action, that throw means the function never returns a value. From React's point of view, the action transition never settled. The transition that owns the pending flag has no terminal state to flip to.
When the client navigates to /dashboard, the form unmounts, the transition is abandoned, no harm done. The problem appears when the user comes back. The browser's back-forward cache or Next.js's client cache can restore the previous component tree, including the abandoned transition state. The useActionState reducer has not received a settle signal, so the snapshot it returns still reports isPending: true. The component renders with the spinner still showing and the button disabled, even though there is no action running.
There is a second flavour of the same bug. If the server action throws an unexpected error after the redirect call ran but before the throw propagated, the transition does settle, but the error breaks the redirect, and the form is left with both isPending: false and a confusing error message. Either way, the bug surfaces because server actions and client transitions disagree about what "done" means when a redirect is involved.
The useActionState reference explicitly notes that the action should return a value. redirect() does not. That is the core mismatch.
The Fix
Two changes. Move the redirect out of the action body, and reset the form key on navigation so any restored transition state is dropped.
Change 1: Return a result instead of redirecting inside the action. The action becomes a pure state transition. The client component reads the returned state and triggers the navigation itself with router.push. That gives the transition a value to settle on:
'use server'
export async function signup(
prev: { error: string | null; redirectTo: string | null },
formData: FormData,
) {
const email = formData.get('email')
try {
await createUser(String(email))
return { error: null, redirectTo: '/dashboard' }
} catch (err) {
return { error: 'Could not create account', redirectTo: null }
}
}
The client component watches the state and navigates when redirectTo is set. Because the navigation happens in a useEffect, the transition has already settled by the time the router runs:
'use client'
import { useActionState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { signup } from './actions'
const initial = { error: null, redirectTo: null }
export function SignupForm() {
const [state, formAction, isPending] = useActionState(signup, initial)
const router = useRouter()
useEffect(() => {
if (state.redirectTo) router.push(state.redirectTo)
}, [state.redirectTo, router])
return (
<form action={formAction}>
<input name="email" type="email" required />
<button disabled={isPending}>{isPending ? 'Saving...' : 'Sign up'}</button>
{state.error && <p>{state.error}</p>}
</form>
)
}
isPending now flips back to false the moment the action returns, because the action returns. The redirect happens a tick later, after the transition has settled, so the navigation never strands a pending flag.
Change 2: If you must redirect inside the action, reset the form on remount. Some flows really do need redirect() inside the action, for example when the redirect URL depends on a session value that the client should not see. In that case, key the form on the pathname so a back-navigation rebuilds it cleanly:
'use client'
import { usePathname } from 'next/navigation'
export function SignupFormWrapper() {
const pathname = usePathname()
return <SignupForm key={pathname} />
}
The pathname changes on the way to /dashboard and back, the wrapper sees a new key, React tears down the old tree, and any restored transition state goes with it. Crude but effective.
One more thing worth checking. If the action also calls revalidatePath or revalidateTag before the redirect, run them after a fresh request, not inside the action. A revalidation that fires alongside a redirect can leave the cache in an in-between state where the destination page reads stale data on the first render. Move the revalidation to a route handler or trigger it from the destination page itself once the redirect lands.
The Lesson
useActionState needs the action to return for the pending flag to clear. redirect() throws instead of returning, so the transition never settles, and a remount surfaces the stranded pending state. Return a result with the destination URL and run router.push from a useEffect, or key the form on the pathname so a remount discards the abandoned transition. Either pattern restores the obvious mental model: the spinner stops when the action finishes.
If your Next.js form flows are stuck in this kind of subtle state-management bug, that is a project I get paid to debug. See my services. For a related Server Actions issue from the same Next.js 16 release, read Next.js 16 server actions invalid action error.
Stuck on Server Actions and form state? Get it shipped.