A form that only submits is not finished. Users need validation messages, a pending indicator, and ideally their change on screen before the server confirms it. React 19 ships three hooks that pair naturally with Server Actions: useActionState, useFormStatus and useOptimistic. After this lesson you will be able to validate input with Zod, return errors to the form, show pending states, and apply optimistic updates.
useActionState wraps an action so that whatever it returns becomes component state. The action now receives the previous state first, then the FormData.
// app/signup/SignupForm.tsx
"use client";
import { useActionState } from "react";
import { signup, type SignupState } from "./actions";
const initial: SignupState = {};
export function SignupForm() {
const [state, formAction, pending] = useActionState(signup, initial);
return (
<form action={formAction}>
<input name="email" type="email" />
{state.errors?.email && <p role="alert">{state.errors.email}</p>}
<input name="password" type="password" />
{state.errors?.password && <p role="alert">{state.errors.password}</p>}
<button disabled={pending}>{pending ? "Creating..." : "Sign up"}</button>
</form>
);
}The hook returns the latest state, a wrapped action for the form, and a pending boolean. Because the state is plain data, the same pattern carries success messages or partially saved drafts.
Never trust FormData. The action in the sample at the top of this lesson uses a Zod schema and safeParse, which returns either { success: true, data } or { success: false, error }. Iterating error.issues and keying messages by field name produces a structure the form renders next to each input.
For unexpected failures (database down), catch the error inside the action and return { errors: { form: "Something went wrong" } } rather than throwing, which would trigger the nearest error.tsx.
A reusable submit button rendered inside the form cannot see pending from useActionState. useFormStatus from react-dom reads the status of the nearest parent form:
"use client";
import { useFormStatus } from "react-dom";
export function SubmitButton({ label }: { label: string }) {
const { pending } = useFormStatus();
return <button type="submit" disabled={pending} aria-busy={pending}>{pending ? "Saving..." : label}</button>;
}The hook only works in a component rendered inside the <form>.
For low-risk mutations such as adding a todo, waiting for the server feels slow. useOptimistic shows the expected result immediately and reconciles once the action completes:
"use client";
import { useOptimistic } from "react";
import { addTodo } from "./actions";
type Todo = { id: string; title: string };
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimistic, addOptimistic] = useOptimistic(todos, (current, title: string) => [
...current,
{ id: "temp", title },
]);
async function action(formData: FormData) {
addOptimistic(String(formData.get("title")));
await addTodo(formData);
}
return (
<>
<form action={action}><input name="title" /><button>Add</button></form>
<ul>{optimistic.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
</>
);
}When addTodo revalidates the page, the todos prop arrives with the real record and React discards the temporary entry. If the action throws, React reverts to the previous list.
prevState parameter after switching to useActionState, so FormData arrives in the wrong position.useFormStatus in the component that renders the <form>; it must be a descendant.What is the signature of an action used with `useActionState`?
useActionState returns [state, action, pending]; the action signature becomes (prevState, formData).safeParse and return field errors as plain data.useFormStatus gives pending state to components rendered inside a form.useOptimistic shows the expected result instantly and reverts on failure.error.tsx.Next lesson: API Route Handlers ā expose HTTP endpoints with route.ts for clients that are not your own pages.