Next.js 16 useActionState Returning Stale State Fix

Next.js 16 useActionState returning the previous submission state after a navigation back? Here is why the hook caches it and the fix that resets cleanly.
Next.jsReactServer Actions
June 3, 20266 min read1001 words

The Problem

I hit this on a Next.js 16.2 project running React 19.2 last Tuesday. The contact form uses useActionState to track the result of a server action. After I switched the form's parent route from a server component to a client component to share some state with a sibling, the hook started returning the previous submission's result the second time the form mounted. A visitor submits, sees a success message, navigates away, comes back, and the success message from the prior submit is still on screen.

State should reset to the initial value because the form remounted. It does not. The hook reads previous state from a transient store keyed on the action identity, and after the route became a client component that key stopped changing across mounts. React 19's useActionState happily hands back the cached value.

Reproduce:

  1. Page A renders a client component that calls useActionState(submitForm, null)
  2. Submit the form, see the success state render
  3. Navigate to Page B, then back to Page A within 30 seconds
  4. Form renders with the previous success state still in the hook

The Network panel shows no fresh server action invocation. The state is hydrated from React's internal cache, exactly as React 19 promises. The bug is that the cache key is too coarse.

Why It Happens

useActionState was promoted out of canary in React 19 and ships with two behaviours that surprise teams migrating from useFormState: it preserves state across re-renders, and it tries to preserve state across remounts when the action reference is identical.

The "identical action reference" check is what catches us. When the server action is imported once at the top of a client module, module evaluation hashes the action to the same identifier on every mount. React 19 sees the same action across mounts and assumes you want the previous state back. On a navigation back, the route remounts, React rebinds the hook, and the previous state is hydrated from React's internal action result map.

The React 19 reference for useActionState calls this out as "preserving state across submissions," but the docs do not flag that it also preserves state across remounts when the App Router keeps the route alive in its loader cache.

The App Router keeps loader data and React state alive on prefetched routes for the full prefetch window. When a visitor navigates to Page B and back inside that window, the route never fully tears down. React reuses the action result map. Welcome to stale state.

The Fix

Three patterns. Pick the one that matches the form's lifecycle.

Pattern 1 is the cleanest for short-lived forms where the previous result should never persist. Force a fresh hook identity by keying the inner component:

'use client'
import { useId } from 'react'
import { useActionState } from 'react'
import { submitContact } from './actions'

export function ContactForm() {
  const mountId = useId()
  return <ContactFormInner key={mountId} />
}

function ContactFormInner() {
  const [state, formAction, pending] = useActionState(submitContact, null)

  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <button disabled={pending}>Send</button>
      {state?.ok && <p>Thanks, we will reply within a day.</p>}
      {state?.error && <p>{state.error}</p>}
    </form>
  )
}

useId returns a stable identifier per mount but a different one across full remounts, so the inner component sees a new key and React drops the cached state. This is what I ship by default.

Pattern 2 keeps the hook for in-place submissions but resets on a route change. Useful when the same form is reused across slugs:

'use client'
import { useActionState, useEffect } from 'react'
import { usePathname } from 'next/navigation'
import { submitContact } from './actions'

const initial = { ok: false, error: null, key: 0 }

export function ContactForm() {
  const pathname = usePathname()
  const [state, formAction, pending] = useActionState(submitContact, initial)

  useEffect(() => {
    if (state.ok || state.error) {
      const form = document.querySelector('form[data-contact]') as HTMLFormElement | null
      form?.reset()
    }
  }, [pathname, state.ok, state.error])

  return (
    <form action={formAction} data-contact>
      <input name="email" type="email" required />
      <button disabled={pending}>Send</button>
      {state.ok && <p>Thanks, we will reply within a day.</p>}
    </form>
  )
}

Pattern 3 is the right answer for multi-step forms where you need granular control. Have the server action accept a clientSubmitId and treat a differing ID as a hard reset:

'use server'

type State = {
  ok: boolean
  error: string | null
  clientId: string | null
}

export async function submitContact(
  prev: State,
  formData: FormData,
): Promise<State> {
  const clientId = formData.get('clientSubmitId') as string

  if (prev?.clientId && prev.clientId !== clientId) {
    return { ok: false, error: null, clientId }
  }

  // normal handling
  return { ok: true, error: null, clientId }
}

Then the client form sends a fresh clientSubmitId on every mount via crypto.randomUUID() in a hidden input:

const submitId = useMemo(() => crypto.randomUUID(), [])
return (
  <form action={formAction}>
    <input type="hidden" name="clientSubmitId" value={submitId} />
    <input name="email" type="email" required />
    <button disabled={pending}>Send</button>
  </form>
)

Verify by submitting, navigating away inside the 30-second prefetch window, navigating back, and confirming the form renders with the initial state. If the success message is still there the key prop is on the wrong component. Pop open React DevTools Profiler and confirm the inner component shows "mounted" instead of "updated."

For a related stale-state bug after the 16 upgrade, see Next.js use cache returning stale data.

If a Next.js 16 form regression is blocking a launch, that is the kind of thing I unstick fast. See my services.

Stuck on a Next.js 16 form regression? Get it unstuck.

Back to blogStart a project