Skip to content

Forms and Progressive Enhancement

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.

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.

// app/new-todo.tsx — works before JS, upgrades after hydration
import { createTodo } from './actions'
export function NewTodoForm() {
return (
<form action={createTodo}>
<input name="title" required />
<button type="submit">Add todo</button>
</form>
)
}

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.

// app/actions.ts — return field errors instead of throwing
'use server'
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 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 } })
return { errors: {} }
}
// app/new-todo.tsx — bind the action with useActionState
'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 reads the pending state of the enclosing form. Put it in a child component of the form so the button can disable itself while the action runs, without threading props down.

// app/submit-button.tsx — a child of the form
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button disabled={pending} type="submit">
{pending ? 'Saving…' : 'Add todo'}
</button>
)
}

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.

// app/thread.tsx — optimistic update before the action resolves
'use client'
import { useOptimistic } from 'react'
import { send } from './actions'
type Message = { message: string }
export function Thread({ messages }: { messages: Message[] }) {
const [optimistic, addOptimistic] = useOptimistic<Message[], string>(
messages,
(state, next) => [...state, { message: next }],
)
async function action(formData: FormData) {
const message = String(formData.get('message'))
addOptimistic(message)
await send(message)
}
return (
<form action={action}>
{optimistic.map((m, i) => <p key={i}>{m.message}</p>)}
<input name="message" />
<button type="submit">Send</button>
</form>
)
}
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"]
Baseline submit then hydrated upgrade
Why does a form action work before JavaScript loads?
What does useActionState give you back?
Where must useFormStatus be called to read the form pending state?
What is the purpose of useOptimistic?