Lesson 51 of 81
Wiring a Server Action straight to a form's action prop gets you a working mutation with zero client JS. useActionState is what turns that into a form that can show validation errors and a pending state without you hand-rolling either.
13 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
<form action={someServerAction}> is the simplest possible wiring: React passes the submitted FormData straight to the action, the browser does a real form POST, and it works even before hydration finishes. But a bare wiring like that has no way to get anything back — if the action rejects the input, there's nowhere for that rejection to land in the UI.
useActionState (from react, not react-dom) closes that gap. It wraps a Server Action and gives you back three things: the action's latest result (whatever it returned, so you can render validation errors or a success message), a wrapped action to hand to <form action={...}> in its place, and a pending boolean for disabling the submit button while the request is in flight. In exchange, the action's own signature changes: it now takes the previous state as its first argument, FormData as its second, matching the (state, formData) => newState shape any reducer-like hook expects.
// app/signup/actions.ts
"use server";
import { z } from "zod";
import { createUser } from "@/lib/db";
const schema = z.object({
email: z.string().email("Enter a valid email address"),
});
export type SignupState = { message: string } | null;
// prevState is required even though this action ignores it — useActionState
// always calls the action as (prevState, formData).
export async function signup(prevState: SignupState, formData: FormData) {
const parsed = schema.safeParse({ email: formData.get("email") });
if (!parsed.success) {
return { message: parsed.error.issues[0].message };
}
await createUser(parsed.data.email);
return { message: "Check your inbox to confirm." };
}
// app/signup/signup-form.tsx
"use client";
import { useActionState } from "react";
import { signup, type SignupState } from "./actions";
export function SignupForm() {
const [state, formAction, pending] = useActionState<SignupState, FormData>(
signup,
null,
);
return (
<form action={formAction}>
<input type="email" name="email" required />
<button disabled={pending}>{pending ? "Signing up…" : "Sign up"}</button>
{state?.message && <p aria-live="polite">{state.message}</p>}
</form>
);
}
useActionState(signup, null) returns state (starts as null, becomes whatever signup last returned), formAction (the version of signup you actually pass to the form), and pending. React calls signup(state, formData) on every submit, so the second run sees the first run's return value as prevState — that's the whole reason the action's signature grows an extra leading parameter the moment you introduce this hook. The aria-live="polite" region means the error announces itself to screen readers the instant state.message changes, without you writing any focus-management code by hand.
useActionState, imported from react — not useFormState from react-dom. useFormState was the hook's name during React's canary/experimental period; it was renamed before the stable release. Code, tutorials, or AI output written against older React will still say useFormState — treat that name as a signal you're looking at outdated material, not a valid alternative API.useActionState changes the action's function signature, everywhere it's used. The action now takes (prevState, formData) instead of just (formData). If the same Server Action is also called directly elsewhere (not through this hook), that call site needs updating too — the two calling conventions aren't interchangeable.state starts out as whatever initial value you pass — it is not undefined until first submit. useActionState(signup, null) means state is null on first render, so guard against that shape (state?.message) rather than assuming a submission already happened.pending from useActionState and the pending from useFormStatus answer the same question from two different places. useActionState's pending is available right where you called the hook; useFormStatus's only works in a component nested inside the <form>, which is exactly why a separate <SubmitButton> component reaching for it needs to live below the form tag, not beside it.<form action={fn}> with no useActionState still works and still progressively enhances — you only need the hook when you actually want to surface a result back into the UI. Don't reach for it on every form as a reflex.This lesson assumes the Server Actions fundamentals lesson's core fact — a form's action prop pointing at a 'use server' function submits as a real HTTP POST, working with or without JavaScript. useActionState doesn't change that mechanism; it's a thin React state layer on top of it that captures the action's return value across submissions. It's also a close cousin of useOptimistic: both let a Client Component react to a mutation's outcome, but useActionState waits for the real result while useOptimistic shows a guessed one before the server has responded at all.