React
React 19 Forms with useActionState and Server Actions
Build a React 19 form that submits to a Server Action, shows field errors, and disables the button while the action is pending.
- React
- Forms
- Server Actions
- Next.js
On this page
Most form bugs are state bugs. The button stays enabled during the request, the error from the previous submit flashes under the new one, and a double click creates two rows. React 19 gives you a smaller loop. The form posts to an action. The action returns the next state. useActionState holds that state, and a pending flag tells the button to wait. You stop mirroring the network in five separate useState calls.
This pattern fits the Next.js App Router because Server Actions already run on the server with the user's cookies. It also fits a client form that needs field-level errors. The action stays a plain async function that returns data. The component stays a view of that data. Validation still belongs in the action, not only in the browser, because a client check is a convenience and a server check is the boundary.
The state the action returns
Decide the shape before you write JSX. A useful result has three fields: a status, a message a human can read, and a map of field errors keyed by input name. Avoid throwing for ordinary validation. A thrown error becomes an error boundary and the user loses what they typed. Reserve throws for programmer mistakes and unexpected outages.
export type FormState = {
status: "idle" | "error" | "success";
message: string;
fieldErrors: Record<string, string>;
};
export const idleState: FormState = {
status: "idle",
message: "",
fieldErrors: {},
};
A Server Action that returns that state
The first argument to the action used with useActionState is the previous state. The second is the FormData from the submit. Read fields with formData.get, reject anything that is not a string, and return a new state object. Do not mutate the previous state. React compares the result you return.
"use server";
import { idleState, type FormState } from "@/lib/form-state";
export async function sendFeedback(
_previous: FormState,
formData: FormData,
): Promise<FormState> {
const email = formData.get("email");
const message = formData.get("message");
if (typeof email !== "string" || !email.includes("@")) {
return {
status: "error",
message: "Check the highlighted fields.",
fieldErrors: { email: "Enter a valid email." },
};
}
if (typeof message !== "string" || message.trim().length < 20) {
return {
status: "error",
message: "Check the highlighted fields.",
fieldErrors: { message: "Use at least 20 characters." },
};
}
await saveFeedback({ email, message: message.trim() });
return { ...idleState, status: "success", message: "Received." };
}
declare function saveFeedback(input: {
email: string;
message: string;
}): Promise<void>;
Wire the form
useActionState takes the action and the idle state. It returns the latest state, a form action to pass to the form, and a pending boolean. Put the form action on the form element. Disable the submit button while pending is true. Render field errors next to the inputs they belong to, and render the status message once above the button so a screen reader has a single place to look.
"use client";
import { useActionState } from "react";
import { sendFeedback } from "@/app/feedback/actions";
import { idleState } from "@/lib/form-state";
export function FeedbackForm() {
const [state, formAction, pending] = useActionState(sendFeedback, idleState);
return (
<form action={formAction}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
{state.fieldErrors.email ? <p>{state.fieldErrors.email}</p> : null}
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
{state.fieldErrors.message ? <p>{state.fieldErrors.message}</p> : null}
<button type="submit" disabled={pending}>
{pending ? "Sending" : "Send"}
</button>
{state.message ? <p>{state.message}</p> : null}
</form>
);
}
What pending is for
- Disable the submit control so a double click cannot enqueue two writes.
- Leave the fields editable unless the action is destructive. People fix typos while they wait.
- Do not clear the form until status is success. Clearing on pending throws away the draft if the action fails.
- Keep the success message in state. A toast that unmounts with the form is easy to miss.
Progressive enhancement still works if the form has an action. With JavaScript, React intercepts the submit and calls the action without a full navigation. Without JavaScript, the browser posts the form. If you need both, keep inputs named and avoid storing the only copy of the draft in client state. The FormData on the server is the source of truth.
Where this pattern stops
useActionState is a poor fit for a multi-step wizard that must survive a refresh, and it is a poor fit for a form that uploads hundreds of megabytes. Use it for the forms you already rebuild with useState: contact, settings, comments, and admin edits. When the action starts calling third-party APIs, return a generic message to the user and log the provider error on the server. A raw provider payload in fieldErrors is both a leak and a layout bug.
