useActionState (formerly useFormState) is the canonical way to wire a form to a Server Action and get back state — validation errors, success messages, the new entity.
1// app/contact/actions.ts
2"use server";
3import { z } from "zod";
4
5type State = { ok: boolean; error?: string; data?: { id: string } };
6
7const schema = z.object({
8 email: z.string().email(),
9 message: z.string().min(10).max(2000),
10});
11
12export async function submitContact(prev: State, formData: FormData): Promise<State> {
13 const parsed = schema.safeParse({
14 email: formData.get("email"),
15 message: formData.get("message"),
16 });
17
18 if (!parsed.success) {
19 return { ok: false, error: parsed.error.issues[0].message };
20 }
21
22 const msg = await db.contactMessage.create({ data: parsed.data });
23 return { ok: true, data: { id: msg.id } };
24}Note the signature: (previousState, formData) → newState.
1// app/contact/ContactForm.tsx
2"use client";
3import { useActionState } from "react";
4import { submitContact } from "./actions";
5
6const initial = { ok: false } as const;
7
8export function ContactForm() {
9 const [state, action, pending] = useActionState(submitContact, initial);
10
11 if (state.ok) {
12 return <p className="text-green-600">Thanks — message #{state.data!.id} received.</p>;
13 }
14
15 return (
16 <form action={action} className="space-y-3">
17 <input name="email" type="email" required placeholder="you@example.com" />
18 <textarea name="message" required minLength={10} placeholder="What's up?" />
19 {state.error && <p className="text-red-600 text-sm">{state.error}</p>}
20 <button disabled={pending} className="rounded bg-black px-4 py-2 text-white">
21 {pending ? "Sending…" : "Send"}
22 </button>
23 </form>
24 );
25}The third value, pending, is React's built-in pending boolean — no manual state needed.
Even with JavaScript disabled, this form still submits. The browser POSTs to Next's action endpoint, the server runs the action, the page re-renders with the new state in the URL.
Test it:
That's a real, working website with zero client JS for the form.
A parent form's pending state can be read by any nested Client Component:
1"use client";
2import { useFormStatus } from "react-dom";
3
4export function SubmitButton({ children }: { children: React.ReactNode }) {
5 const { pending } = useFormStatus();
6 return <button disabled={pending}>{pending ? "Saving…" : children}</button>;
7}Drop <SubmitButton>Save</SubmitButton> into any form — it knows when its enclosing form is submitting.
Return a structured error map:
1type State =
2 | { ok: true; data: { id: string } }
3 | { ok: false; errors: Record<string, string> };
4
5if (!parsed.success) {
6 const errors: Record<string, string> = {};
7 for (const issue of parsed.error.issues) {
8 errors[issue.path[0] as string] = issue.message;
9 }
10 return { ok: false, errors };
11}1{!state.ok && state.errors.email && (
2 <p className="text-red-600 text-sm">{state.errors.email}</p>
3)}