Forms and Progressive Enhancement
The idea in one sentence
Section titled “The idea in one sentence”Wiring a Server Action to <form action={action}> gives you a form that submits and mutates even before any JavaScript loads, then upgrades to a richer client experience once React hydrates.
Progressive enhancement for free
Section titled “Progressive enhancement for free”Because the action is attached to a real <form>, the browser can post it with a plain HTTP request. The mutation works with JavaScript disabled or still loading. Once React hydrates the page, the same form is intercepted client-side, so there is no full-page reload — you get the baseline for free and the upgrade automatically.
import { createTodo } from './actions'
export function NewTodoForm() { return ( <form action={createTodo}> <input name="title" required /> <button type="submit">Add todo</button> </form> )}useActionState needs (prevState, formData) — not (formData)
Section titled “useActionState needs (prevState, formData) — not (formData)”Every plain Server Action takes formData as its only argument, as in the section above. useActionState(action, initialState) expects something different: an action whose first parameter is the previous state and whose second parameter is formData. Swapping a plain action into useActionState is not a style choice you can get away with — it is a real TypeScript error, because the two shapes are incompatible.
'use server'
export async function createTodoPlain(formData: FormData) { const title = String(formData.get('title')) return { ok: true, title }}// @expect-error swapping a plain (formData) action into useActionState is a type error'use client'
import { useActionState } from 'react'import { createTodoPlain } from './actions'
export function BadForm() { const [state, formAction] = useActionState(createTodoPlain, { ok: false, title: '' }) return ( <form action={formAction}> <input name="title" /> <button type="submit">Add</button> </form> )}Compiling this against Next.js 16.3.6 / React 19.2.8 gives:
error TS2769: No overload matches this call. Overload 1 of 2, '(action: (state: { ok: boolean; title: string; }) => { ok: boolean; title: string; } | Promise<{ ok: boolean; title: string; }>, initialState: { ok: boolean; title: string; }, permalink?: string | undefined): [state: ...]', gave the following error. Argument of type '(formData: FormData) => Promise<{ ok: boolean; title: string; }>' is not assignable to parameter of type '(state: { ok: boolean; title: string; }) => { ok: boolean; title: string; } | Promise<{ ok: boolean; title: string; }>'. Types of parameters 'formData' and 'state' are incompatible. Type '{ ok: boolean; title: string; }' is missing the following properties from type 'FormData': append, delete, get, getAll, and 7 more. Overload 2 of 2, '(action: (state: { ok: boolean; title: string; }, payload: unknown) => { ok: boolean; title: string; } | Promise<{ ok: boolean; title: string; }>, initialState: { ok: boolean; title: string; }, permalink?: string | undefined): [state: ...]', gave the following error. Argument of type '(formData: FormData) => Promise<{ ok: boolean; title: string; }>' is not assignable to parameter of type '(state: { ok: boolean; title: string; }, payload: unknown) => { ok: boolean; title: string; } | Promise<{ ok: boolean; title: string; }>'. Types of parameters 'formData' and 'state' are incompatible. Type '{ ok: boolean; title: string; }' is missing the following properties from type 'FormData': append, delete, get, getAll, and 7 more.TypeScript is telling you, in a roundabout way, that useActionState tried to treat your returned state object as if it were the next formData argument. The fix is to give the action a prevState parameter — authorize and validate stay exactly where they were.
useActionState for result, pending, and errors
Section titled “useActionState for result, pending, and errors”useActionState(action, initialState) wraps an action so you get back the latest returned state, a form action to bind, and a pending flag. Your action takes the previous state as its first argument and returns the new state — perfect for surfacing validation errors next to the fields. As with every action in this module, authorize with verifySession() before validating the input.
export const db = { todo: { create: async (_args: { data: { title: string; userId?: string } }) => {}, },}'use server'
import { verifySession } from '@/app/lib/dal'import { db } from '@/lib/db'import { z } from 'zod'
const schema = z.object({ title: z.string().min(1, 'Title is required') })
export async function createTodo(prevState: unknown, formData: FormData) { const { userId } = await verifySession() const parsed = schema.safeParse({ title: formData.get('title') }) if (!parsed.success) { return { errors: parsed.error.flatten().fieldErrors } } await db.todo.create({ data: { title: parsed.data.title, userId } }) return { errors: {} }}'use client'
import { useActionState } from 'react'import { createTodo } from './actions'
export function NewTodoForm() { const [state, formAction, pending] = useActionState(createTodo, { errors: {} })
return ( <form action={formAction}> <input name="title" /> {state.errors?.title && <p>{state.errors.title[0]}</p>} <button disabled={pending} type="submit">Add todo</button> </form> )}useFormStatus must be called from a child of the form
Section titled “useFormStatus must be called from a child of the form”useFormStatus reads the pending state of the enclosing form — enclosing meaning a real ancestor in the render tree, not “the component that happens to render the form tag.” Calling it in the same component that renders <form> is a common mistake: it compiles, it runs, and it silently never reports pending: true, because that component is not a descendant of its own form.
'use client'
import { useFormStatus } from 'react-dom'
export function FormWrong({ action }: { action: (formData: FormData) => void }) { const { pending } = useFormStatus() return ( <form action={action}> <input name="title" /> <button disabled={pending} type="submit"> {pending ? 'Saving...' : 'Save (wrong)'} </button> </form> )}'use client'
import { useFormStatus } from 'react-dom'
function SubmitStatus() { const { pending } = useFormStatus() return ( <button disabled={pending} type="submit"> {pending ? 'Saving...' : 'Save (right)'} </button> )}
export function FormRight({ action }: { action: (formData: FormData) => void }) { return ( <form action={action}> <input name="title" /> <SubmitStatus /> </form> )}Both compile and both run. The difference only shows up while an action is in flight, so it is easy for an AI-generated snippet to look correct and still be wrong. Running both against the same 15-second slowSave action in a real browser (Next.js 16.3.6, React 19.2.8) and reading each button’s status 200ms after clicking submit:
FormRight -> "pending: true" (SubmitStatus is a child of <form>)FormWrong -> "pending: false" (useFormStatus is in the same component as <form>, never true)useOptimistic for instant feedback, with rollback on failure
Section titled “useOptimistic for instant feedback, with rollback on failure”useOptimistic lets you show the expected result immediately, before the server responds. You render the optimistic list, add the new item on submit, and React reconciles once the real data arrives — and if the action throws instead of resolving, React discards the pending optimistic update and the UI reverts to the last confirmed state.
'use server'
export async function send(message: string) { await new Promise((r) => setTimeout(r, 300)) if (message === 'boom') { throw new Error('send failed') }}'use client'
import { useOptimistic, useState } from 'react'import { send } from './thread-actions'
type Message = { message: string }
export function Thread() { const [messages, setMessages] = useState<Message[]>([{ message: 'hello' }]) const [optimistic, addOptimistic] = useOptimistic<Message[], string>( messages, (state, next) => [...state, { message: next }], ) const [status, setStatus] = useState('idle')
async function action(formData: FormData) { const message = String(formData.get('message')) addOptimistic(message) setStatus('sending') try { await send(message) setMessages((m) => [...m, { message }]) setStatus('idle') } catch (e) { setStatus('error: ' + (e as Error).message) } }
return ( <form action={action}> {optimistic.map((m, i) => <p key={i}>{m.message}</p>)} <input name="message" /> <button type="submit">Send</button> </form> )}Submitting the message "boom" against an action that rejects that exact value (Next.js 16.3.6, React 19.2.8, in a real browser) produces this sequence:
before submit -> list: "hello"100ms later -> list: "hello, boom" (optimistic entry showing already)600ms later -> list: "hello" (action rejected — optimistic entry rolled back) status: "error: send failed"The optimistic message never becomes part of the confirmed messages state, because setMessages only runs in the try branch — useOptimistic itself throws away the pending update once the action’s promise rejects.
graph TD
A["form action = Server Action"] --> B{"Has JS hydrated?"}
B -->|"No"| C["Plain HTTP POST, full navigation"]
B -->|"Yes"| D["Client intercepts, no reload"]
D --> E["useActionState + useFormStatus + useOptimistic"]
Mental Model
Section titled “Mental Model”useActionState, useFormStatus, and useOptimistic are three independent lenses on the same in-flight request, not three parts of one API. useActionState lives on the component that owns the action and its result. useFormStatus lives on a descendant of the <form> and only ever reports that form’s status. useOptimistic lives wherever you render the list that should feel instant, and it unwinds itself automatically the moment the action’s promise rejects.
Trade-offs
Section titled “Trade-offs”| Hook | What it gives you | Cost |
|---|---|---|
useActionState | Return value, bound form action, pending flag, in one hook | Action signature must be (prevState, formData) — a real type error if it isn’t |
useFormStatus | No prop drilling for pending state | Only works from a descendant of the <form> — not the component that renders it |
useOptimistic | Instant feedback before the server responds | You must handle the rejection path yourself, or the optimistic entry lingers forever |
Common Misconceptions
Section titled “Common Misconceptions”- “Any async function works with
useActionState.” Only one whose first parameter is the previous state does — a plain(formData) => ...action fails to type-check. - “
useFormStatusreads whichever form is nearest in the file.” It reads the nearest form ancestor in the render tree. The component that renders<form>itself is not that form’s descendant. - “
useOptimisticneeds a manual rollback call.” It does not — the rollback is automatic once the action’s promise rejects. What you must write yourself is the error state your UI shows afterward.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet uses these hooks, check for these before trusting it:
- Does the action passed to
useActionStatetake(prevState, formData), or does it still take onlyformDatafrom an earlier, plain-action version? - Is
useFormStatuscalled in a component that is a child of<form>, or in the same component that renders the<form>tag? - If
useOptimisticis used, does the surrounding code have atry/catch(or.catch) around the action call, so the rejection path actually runs? Without it, the promise rejection is unhandled even though the optimistic UI still rolls back. - Does the confirmed state (
setMessageshere) only update inside the success path, so a failed action can never “confirm” data that was never written?
🐛 Spot the bug
'use client'
import { useFormStatus } from 'react-dom'
export function SaveButton({ action }: { action: (formData: FormData) => void }) {const { pending } = useFormStatus()return ( <form action={action}> <input name="title" /> <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button> </form>)}Show the answer
useFormStatus is called in SaveButton, the same component that renders the <form> element — not in a component nested inside it. Per React’s own docs, useFormStatus “will only return status information for a parent <form>,” and this component is not a child of its own form. The button compiles and renders, but pending stays false for the entire submission — proven in a real browser against a 15-second action, useFormStatus here read pending: false the whole time while an identical hook one component level down read pending: true. The fix is to extract the button into its own component and render it as a child of <form>.