Form Actions - Wire async functions to forms with built-in pending and error state
Recipe
"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.
Working Example
// 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:
useActionStatemanaging form state across submissions (errors, success message)useFormStatusin a child component to show pending state on the submit button- Field-level error display from the action's return value
- Progressive enhancement -- the form works without JavaScript if paired with a server action
Deep Dive
How It Works
<form action={fn}>-- React 19 extends the nativeactionattribute to accept async functions. When the form is submitted, React callsfn(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 newstate. The optionalpermalinkparameter 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.- Progressive enhancement -- When using server actions with
useActionState, forms can work before JavaScript loads. Thepermalinkargument specifies where to redirect after the server action completes in the no-JS case. isPendingfromuseActionStatereflects whether the action is currently executing. This is available in the same component that callsuseActionState, unlikeuseFormStatuswhich only works in descendants.
Variations
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>
);
}TypeScript Notes
useActionStateis generic:useActionState<State>(fn: (prev: State, formData: FormData) => Promise<State>, initial: State).useFormStatusreturns{ pending: boolean; data: FormData | null; method: string; action: string | ((formData: FormData) => void) | null }.- The action function signature is
(previousState: State, formData: FormData) => State | Promise<State>. - The
permalinkparameter inuseActionStateis typed asstring | undefined.
Gotchas
useFormStatusmust 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.- Action return value replaces state entirely -- Unlike a reducer, the return value is the whole new state, not a partial update. Fix: Spread previous state if you want to merge:
return { ...prev, error: "..." }. - Form is not automatically reset -- Unlike native form submissions, React form actions do not reset the form on success. Fix: Use a
useRefon the form and callformRef.current?.reset()in an effect. useActionStateimport location -- It is imported from"react", not"react-dom". The olduseFormStatewas in"react-dom"and is now deprecated. Fix:import { useActionState } from "react".- Uncontrolled inputs after action -- If your action returns errors but you use uncontrolled inputs, the user's values are preserved in the DOM. If you use controlled inputs, you must restore them from state. Fix: Prefer uncontrolled inputs with form actions, or sync state from
formData. - Multiple forms on the same page -- Each
useActionStatecall is independent. Submitting one form does not affect another. Fix: This is expected behavior but be aware thatuseFormStatusonly reflects the nearest ancestor form.
Alternatives
| 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 |
FAQs
What is the difference between passing a function to form action vs using onSubmit?
<form action={fn}>lets React manage the pending state, error handling, and optimistic updates automatically- The traditional
onSubmit+preventDefault+useStatepattern requires manual state management - Form actions also enable progressive enhancement when paired with server actions
What does useActionState return and how do you use it?
- It returns
[state, wrappedAction, isPending] stateis updated each time the action completes, based on what the action returnswrappedActionis passed to<form action={}>andisPendingindicates if the action is currently executing
Where is useFormStatus imported from and how does it differ from useActionState's isPending?
useFormStatusis imported from"react-dom", whileuseActionStateis from"react"useFormStatusmust be called from a component rendered inside a<form>(a descendant)useActionState'sisPendingis available in the same component that renders the form
How do you display field-level validation errors with form actions?
Return 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: {} };
}How do you reset a form after a successful submission?
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.
Can you have multiple submit buttons with different actions on the same form?
Yes. Use the formAction attribute on individual buttons:
<form>
<input name="name" />
<button formAction={saveAction}>Save</button>
<button formAction={deleteAction}>Delete</button>
</form>What is the permalink parameter in useActionState?
- It is an optional string URL that enables progressive enhancement for server actions
- When JavaScript has not loaded yet, the form submits to this URL and the server action executes
- After completion, the browser redirects to the permalink
Gotcha: Why does useFormStatus always return pending: false in my component?
useFormStatusmust be called from a descendant of the<form>element, not the same component that renders it- Extract the submit button into a separate child component that calls
useFormStatus
Gotcha: The action return value replaces state entirely instead of merging. How do I handle partial updates?
- Unlike a reducer, the return value is the whole new state, not a partial update
- Spread the previous state if you want to merge:
return { ...prev, error: "..." } - Design your state shape to be fully returned from every action path
How do you type the useActionState hook in TypeScript?
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>.
How is useFormStatus typed in TypeScript?
- Returns
{ pending: boolean; data: FormData | null; method: string; action: string | ((formData: FormData) => void) | null } datais theFormDatabeing submitted (or null when not pending)methodreflects the HTTP method of the form
Should I use controlled or uncontrolled inputs with form actions?
- Prefer uncontrolled inputs with form actions -- user values are preserved in the DOM between submissions
- If using controlled inputs, you must restore their values from state when the action returns errors
- Uncontrolled inputs reduce boilerplate and work naturally with
FormData
Related
- Server Actions -- Server functions that form actions can invoke
- useOptimistic -- Adding optimistic updates to form submissions
- Overview -- Full list of React 19 features