The Problem
Shipped a Next.js 16.2 upgrade for a SaaS client on Friday. By Monday morning support had three tickets that all looked the same: users would submit a form, see the success message flash for a fraction of a second, and then land on the dashboard with no toast and no confirmation that anything had happened. Hitting the browser back button took them to the form with empty fields and no error state, even when the server had rejected the submission.
The form was a textbook useActionState + Server Action setup. Worked in 15.3, broken in 16.2:
'use client'
import { useActionState } from 'react'
import { createInvoice } from './actions'
export function InvoiceForm() {
const [state, formAction, pending] = useActionState(createInvoice, {
status: 'idle',
message: '',
})
return (
<form action={formAction}>
<input name="amount" />
<button disabled={pending}>Submit</button>
{state.status === 'error' && <p>{state.message}</p>}
</form>
)
}
The Server Action ended with redirect('/dashboard'). In 15.3 the success state stuck around long enough for a useEffect on the dashboard to read it via a query param. In 16.2 the entire useActionState tuple resets to its initial value before the destination renders, so the toast never fires and any error case after a failed redirect loses the form input the user typed.
Why It Happens
useActionState keeps its state in the client component instance. The instance only survives if React keeps the component mounted across the action's effect. In 16.2, the rendering pipeline around Server Action redirects changed in two ways that compound.
First, redirect() now throws a RedirectError that is caught at the action boundary and converted into a navigation instruction sent back on the next-action response. That part was true in 15.3 too, but the navigation used to go through the same router transition as a client-side link, which preserved the calling client component while React reconciled the new route. In 16.2, action redirects use the new experimental_pprFallbacks path internally, and the calling component unmounts before the new tree mounts. When it unmounts, its useActionState is gone.
Second, React 19.2 tightened the contract around useActionState's state continuity. The hook documentation now says explicitly that "state is not preserved across navigation," which used to be a quiet implementation detail and is now a hard rule. The implication is that any UI you wanted to show on the destination page has to travel with the navigation, not live in the calling component's state.
The release notes call this out under a single sentence in the migration guide that is easy to skip. The Server Actions reference recommends the new pattern in passing, but doesn't flag the breaking behaviour for projects that were relying on the old continuity by accident.
The Fix
Two patterns depending on what you are trying to show on the destination page.
Pattern 1: Move user feedback to the destination via search params or cookies. If the success message belongs on /dashboard, encode it in the redirect and read it server-side:
'use server'
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
export async function createInvoice(prev, formData) {
const amount = Number(formData.get('amount'))
if (!amount || amount <= 0) {
return { status: 'error', message: 'Amount must be greater than zero' }
}
const invoice = await db.invoices.create({ data: { amount } })
revalidatePath('/dashboard')
redirect(`/dashboard?created=${invoice.id}`)
}
Then on /dashboard/page.tsx, await the search params and surface the message from there. The form component on the previous route is gone by the time the toast appears, which is fine because the destination owns the success UI now.
Pattern 2: Keep error state on the current page, do not redirect on failure. This is the rule I now apply on every form. Redirect on success only. Return state on failure. That way useActionState only has to survive a re-render of the same component, which it does just fine:
'use server'
import { redirect } from 'next/navigation'
export async function createInvoice(prev, formData) {
const amount = Number(formData.get('amount'))
if (!amount || amount <= 0) {
return {
status: 'error',
message: 'Amount must be greater than zero',
values: { amount: formData.get('amount') as string },
}
}
try {
const invoice = await db.invoices.create({ data: { amount } })
redirect(`/dashboard?created=${invoice.id}`)
} catch (err) {
if (err.message === 'NEXT_REDIRECT') throw err
return {
status: 'error',
message: 'Could not create invoice. Try again.',
values: { amount: formData.get('amount') as string },
}
}
}
Re-throwing NEXT_REDIRECT is the part most people miss. redirect() works by throwing, so if you wrap it in a try/catch, you have to let the redirect error propagate or the action silently swallows it and falls through to the catch branch with no useful information.
In the client component, mirror state.values back into the form inputs so a failed submission does not blank the user's typing:
<input name="amount" defaultValue={state.values?.amount ?? ''} />
defaultValue not value. The form is uncontrolled, the server-returned state seeds the next render only. Using value will lock the input and break typing.
Verify the boundary. A two-step smoke test catches both regressions:
# Submit invalid data, confirm the error state renders without navigation
curl -X POST https://staging.app.com/invoices -F amount=-5
# Submit valid data, confirm /dashboard?created=... renders the toast
curl -L -X POST https://staging.app.com/invoices -F amount=100
If the first call returns a 303 with a Location header, the action is redirecting on the failure path and your try/catch is swallowing the wrong error. If the second call lands on /dashboard without the ?created= parameter, the action is redirecting before the database write completes.
The mental model is simple once you internalise it: useActionState belongs to the page that owns the form, and nothing about it survives a route change. Build the success UI on the destination, keep the error UI on the source.
If your Next.js 16 migration left forms behaving in ways your users notice, that is the kind of bug I get hired to fix. See my services. For a related Server Action regression in the same release, read Next.js 16.2 searchParams Promise TypeError.