Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
async function subscribe(_prev: string, formData: FormData): Promise<string> {
const email = formData.get("email") as string;
const res = await fetch("/api/subscribe", {
method: "POST",
body: JSON.stringify({ email }),
});
if (!res.ok) return "Something went wrong.";
return "Subscribed!";
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Subscribing..." : "Subscribe"}
</button>
);
}
export default function NewsletterForm() {
const [message, formAction, isPending] = useActionState(subscribe, "");
return (
<form action={formAction}>
<input name="email" type="email" required placeholder="you@example.com" />
<SubmitButton />
{message && <p>{message}</p>}
</form>
);
}When to reach for this: Whenever you have a form that submits data -- signups, search, CRUD operations. Form actions replace the manual onSubmit + preventDefault + useState pattern.
// A contact form with validation, error display, and progressive enhancement
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
type FormState = {
success: boolean;
errors: Record<string, string>;
message: string;
};
const initialState: FormState = {
success: false,
errors: {},
message: "",
};
async function submitContact(_prev: FormState, formData: FormData): Promise<FormState> {
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const body = formData.get("body") as string;
// Client-side validation
const errors: Record<string, string> = {};
if (!name || name.length < 2) errors.name = "Name must be at least 2 characters.";
if (!email || !email.includes("@")) errors.email = "Please enter a valid email.";
if (!body || body.length < 10) errors.body = "Message must be at least 10 characters.";
if (Object.keys(errors).length > 0) {
return { success: false, errors, message: "Please fix the errors below." };
}
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, body }),
});
if (!res.ok) throw new Error("Server error");
return { success: true, errors: {}, message: "Message sent! We'll be in touch." };
} catch {
return { success: false, errors: {}, message: "Failed to send. Please try again." };
}
}
function FieldError({ error }: { error?: string }) {
if (!error) return null;
return <p className="text-red-500 text-sm mt-1">{error}</p>;
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{pending ? "Sending..." : "Send Message"}
</button>
);
}
export default function ContactForm() {
const [state, formAction, isPending] = useActionState(submitContact, initialState);
return (
<form action={formAction} className="space-y-4 max-w-md">
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" className="border w-full p-2 rounded" />
<FieldError error={state.errors.name} />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" className="border w-full p-2 rounded" />
<FieldError error={state.errors.email} />
</div>
<div>
<label htmlFor="body">Message</label>
<textarea id="body" name="body" rows={4} className="border w-full p-2 rounded" />
<FieldError error={state.errors.body} />
</div>
<SubmitButton />
{state.message && (
<p className={state.success ? "text-green-600" : "text-red-600"}>
{state.message}
</p>
)}
</form>
);
}What this demonstrates:
useActionState managing form state across submissions (errors, success message)useFormStatus in a child component to show pending state on the submit button<form action={fn}> -- React 19 extends the native action attribute to accept async functions. When the form is submitted, React calls fn(formData) and manages the pending lifecycle.useActionState(action, initialState, permalink?) -- Wraps an action function and returns [state, wrappedAction, isPending]. Each time the form is submitted, action(prevState, formData) is called and the returned value becomes the new state. The optional permalink parameter enables progressive enhancement for server actions.useFormStatus() -- Must be called from a component rendered inside a <form>. Returns { pending, data, method, action } reflecting the submission state of the nearest parent form.useActionState, forms can work before JavaScript loads. The permalink argument specifies where to redirect after the server action completes in the no-JS case.isPending from useActionState reflects whether the action is currently executing. This is available in the same component that calls useActionState, unlike useFormStatus which only works in descendants.Multiple submit buttons with different actions:
"use client";
import { useActionState } from "react";
export default function ItemForm() {
const [saveResult, saveAction] = useActionState(saveItem, null);
const [deleteResult, deleteAction] = useActionState(deleteItem, null);
return (
<form>
<input name="name" />
<button formAction={saveAction}>Save</button>
<button formAction={deleteAction}>Delete</button>
</form>
);
}Using useFormStatus for a global loading indicator:
"use client";
import { useFormStatus } from "react-dom";
export function FormProgress() {
const { pending, data } = useFormStatus();
if (!pending) return null;
return (
<div className="fixed top-0 left-0 w-full h-1 bg-blue-500 animate-pulse" />
);
}
// Use inside any form
<form action={myAction}>
<FormProgress />
{/* ... fields ... */}
</form>Reset form after success:
"use client";
import { useActionState, useRef, useEffect } from "react";
export default function ResetableForm() {
const [state, formAction, isPending] = useActionState(submitData, { success: false });
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (state.success) {
formRef.current?.reset();
}
}, [state]);
return (
<form ref={formRef} action={formAction}>
<input name="item" required />
<button type="submit" disabled={isPending}>Add</button>
</form>
);
}useActionState is generic: useActionState<State>(fn: (prev: State, formData: FormData) => Promise<State>, initial: State).useFormStatus returns { pending: boolean; data: FormData | null; method: string; action: string | ((formData: FormData) => void) | null }.(previousState: State, formData: FormData) => State | Promise<State>.permalink parameter in useActionState is typed as string | undefined.useFormStatus must be inside a <form> descendant -- Calling it in the same component that renders the <form> tag returns { pending: false } always. Fix: Extract the submit button into a child component.return { ...prev, error: "..." }.useRef on the form and call formRef.current?.reset() in an effect.useActionState import location -- It is imported from "react", not "react-dom". The old useFormState was in "react-dom" and is now deprecated. Fix: import { useActionState } from "react".formData.useActionState call is independent. Submitting one form does not affect another. Fix: This is expected behavior but be aware that useFormStatus only reflects the nearest ancestor form.| Approach | When to choose |
|---|---|
| Form Actions + useActionState | Standard React 19 forms with built-in pending state |
| react-hook-form | Complex client-side validation, field arrays, watch behavior |
| Formik | Legacy projects already using Formik |
| Manual onSubmit + fetch | Full control over request lifecycle, custom headers |
| Server action only (no client state) | Simple mutations that redirect after completion |
| Conform | Progressive enhancement with Zod integration for server actions |
<form action={fn}> lets React manage the pending state, error handling, and optimistic updates automaticallyonSubmit + preventDefault + useState pattern requires manual state management[state, wrappedAction, isPending]state is updated each time the action completes, based on what the action returnswrappedAction is passed to <form action={}> and isPending indicates if the action is currently executinguseFormStatus is imported from "react-dom", while useActionState is from "react"useFormStatus must be called from a component rendered inside a <form> (a descendant)useActionState's isPending is available in the same component that renders the formReturn an errors object from the action and render it per field:
async function submit(_prev: State, formData: FormData) {
const errors: Record<string, string> = {};
if (!formData.get("name")) errors.name = "Required";
if (Object.keys(errors).length) return { errors };
// ... save data
return { errors: {} };
}const formRef = useRef<HTMLFormElement>(null);
const [state, formAction] = useActionState(submit, initial);
useEffect(() => {
if (state.success) formRef.current?.reset();
}, [state]);
<form ref={formRef} action={formAction}>...</form>React form actions do not reset the form automatically unlike native form submissions.
Yes. Use the formAction attribute on individual buttons:
<form>
<input name="name" />
<button formAction={saveAction}>Save</button>
<button formAction={deleteAction}>Delete</button>
</form>useFormStatus must be called from a descendant of the <form> element, not the same component that renders ituseFormStatusreturn { ...prev, error: "..." }useActionState<State>(
fn: (prev: State, formData: FormData) => Promise<State>,
initial: State
): [State, (formData: FormData) => void, boolean]The action function signature is (previousState: State, formData: FormData) => State | Promise<State>.
{ pending: boolean; data: FormData | null; method: string; action: string | ((formData: FormData) => void) | null }data is the FormData being submitted (or null when not pending)method reflects the HTTP method of the formFormDataReviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥