Forms with Actions
AdvanceduseActionState and useFormStatus give a form its errors and its pending state without any client state at all — and the form still submits with JavaScript disabled.
Overview
The React track built forms out of controlled inputs, submit flags and error state. In the App Router most of that is handed back to the platform: a form posts to a server action, the action returns a result, and two hooks expose the result and the in-flight state to the UI. The part worth appreciating is progressive enhancement — because it is a real form posting to a real endpoint, it works before hydration and without JavaScript, which no amount of client-side form code achieves.
useActionState
The action becomes a reducer over form state.
// The action takes the previous state as its first argument
'use server'
export async function signup(prevState, formData: FormData) {
const parsed = SignupSchema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors, values: Object.fromEntries(formData) }
}
try {
await createUser(parsed.data)
} catch (e) {
if (e.code === 'P2002') return { errors: { email: ['Already registered'] } }
return { message: 'Something went wrong. Please try again.' }
}
redirect('/welcome')
}
// The client side
'use client'
import { useActionState } from 'react' // React 19; useFormState in 18
export function SignupForm() {
const [state, formAction, isPending] = useActionState(signup, {})
return (
<form action={formAction}>
<label htmlFor="email">Email</label>
<input id="email" name="email" defaultValue={state.values?.email}
aria-invalid={!!state.errors?.email}
aria-describedby="email-error" />
{state.errors?.email && <p id="email-error" role="alert">{state.errors.email[0]}</p>}
<button disabled={isPending}>{isPending ? 'Creating…' : 'Create account'}</button>
{state.message && <p role="alert">{state.message}</p>}
</form>
)
}
// Note defaultValue, not value — the inputs stay uncontrolled, and
// the returned values repopulate them after a failed submit.useFormStatus
Pending state for a child component, without prop drilling.
'use client'
import { useFormStatus } from 'react-dom'
// It reads the status of the nearest PARENT form, so the button can
// be a reusable component that knows nothing about the form:
export function SubmitButton({ children }) {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending} aria-busy={pending}>
{pending ? 'Saving…' : children}
</button>
)
}
// It must be inside the <form>, not the component that renders it:
<form action={formAction}>
<SubmitButton>Create account</SubmitButton> {/* works */}
</form>
<SubmitButton /> {/* pending is always false */}
// This is what makes a shared submit button possible across every
// form in the app.
// Progressive enhancement is the quiet win: before hydration, this
// is an ordinary HTML form posting to a URL. It works on a slow
// connection where the JS has not arrived, and it works with
// JavaScript disabled entirely.Validation on Both Sides
One schema, used in the browser for speed and on the server for truth.
// shared/schemas.ts — imported by both
export const SignupSchema = z.object({
email: z.string().email('That does not look like an email'),
password: z.string().min(8, 'At least 8 characters'),
})
// Server: the real check, inside the action (see above).
// Client: instant feedback, optional, purely a convenience.
// With react-hook-form, keep the action and add client validation:
const { register, handleSubmit } = useForm({ resolver: zodResolver(SignupSchema) })
<form action={formAction} onSubmit={handleSubmit(() => {})}>
// You lose no-JS support once submission depends on the handler, so
// decide deliberately which matters more for that form.
// Files work without ceremony — it is a real multipart form:
<form action={upload} encType="multipart/form-data">
<input type="file" name="avatar" accept="image/*" />
'use server'
export async function upload(formData: FormData) {
const file = formData.get('avatar') as File
if (file.size > 5_000_000) return { error: 'Under 5MB please' }
// and verify the real bytes, not the declared type
}
// Note the platform body-size limit — Vercel caps a serverless
// request at a few MB, which is why large uploads go direct to
// storage with a signed URL instead.Key Points to Remember
- 1useActionState turns an action into a reducer over form state, returning errors and values without client state
- 2Return the submitted values with the errors and use defaultValue, so a failed submit does not clear the form
- 3useFormStatus reads the nearest parent form, which makes a shared submit button possible
- 4A form posting to an action works before hydration and without JavaScript — real progressive enhancement
- 5Share one schema for both sides: client validation is a convenience, the server check is the rule
Interview Questions
Sign in to ask AriaWhat does useActionState give you that useState would not?
Why must useFormStatus be called inside the form element?
What does progressive enhancement mean for a form built on server actions?
Ask Aria about Forms with Actions
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.