React 19 shipped with a set of features that fundamentally changed how we build forms and handle server interactions. If you're still writing onSubmit handlers with useState for loading states and try/catch for error handling, React 19 has a better way.
The key additions are Actions (async functions in transitions), the form action prop, useActionState, useFormStatus, and useOptimistic. Together, they eliminate most of the boilerplate around form submissions and data mutations.
What Are Actions?
In React 19, functions that use async transitions are called "Actions." The core idea is simple: you pass an async function to useTransition, and React automatically manages the pending state, error handling, and optimistic updates for you.
Before: Manual state management
function UpdateName() {
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const handleSubmit = async () => {
setIsPending(true);
setError(null);
try {
await updateNameOnServer(name);
} catch (err) {
setError((err as Error).message);
} finally {
setIsPending(false);
}
};
return (/* ... */);
}
After: Using Actions with useTransition
function UpdateName() {
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
startTransition(async () => {
const result = await updateNameOnServer(name);
if (result.error) {
setError(result.error);
}
});
};
return (/* ... */);
}
The async transition automatically sets isPending to true at the start of the request and resets it when the final state update is committed. No manual setIsPending calls needed.
The Form Action Prop
React 19 added support for passing functions directly to the action prop of <form> elements. This is the biggest ergonomic improvement — you can now write forms that work with progressive enhancement built in:
Form with action prop
function CreatePost() {
async function createPost(formData: FormData) {
"use server";
const title = formData.get("title") as string;
const body = formData.get("body") as string;
await db.post.create({ data: { title, body } });
redirect("/posts");
}
return (
<form action={createPost}>
<input name="title" placeholder="Title" required />
<textarea name="body" placeholder="Write your post..." />
<button type="submit">Publish</button>
</form>
);
}
When you pass a function to action, React wraps it in an Action automatically. The form is submitted as a transition, the pending state is managed for you, and the form resets automatically after a successful submission.
useActionState
For forms that need to display results or errors from the server, useActionState is the right tool. It takes an action function and returns a wrapped action along with the current state and pending flag:
useActionState example
import { useActionState } from "react";
interface FormState {
message: string;
errors?: { email?: string[] };
}
function SignupForm() {
const [state, formAction, isPending] = useActionState(
async (prevState: FormState, formData: FormData): Promise<FormState> => {
const email = formData.get("email") as string;
if (!email.includes("@")) {
return { message: "", errors: { email: ["Invalid email address"] } };
}
await createAccount(email);
return { message: "Account created successfully!" };
},
{ message: "" }
);
return (
<form action={formAction}>
<input name="email" type="email" />
{state.errors?.email && (
<p className="text-red-500">{state.errors.email[0]}</p>
)}
<button disabled={isPending}>
{isPending ? "Creating..." : "Sign Up"}
</button>
{state.message && <p className="text-green-500">{state.message}</p>}
</form>
);
}
useOptimistic
Optimistic updates make your UI feel instant by showing the expected result before the server confirms it. React 19 makes this a first-class pattern:
Optimistic updates with useOptimistic
import { useOptimistic } from "react";
interface Todo {
id: string;
text: string;
completed: boolean;
}
function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state: Todo[], newTodo: Todo) => [...state, newTodo]
);
async function addTodo(formData: FormData) {
const text = formData.get("text") as string;
const tempTodo = { id: "temp-" + Date.now(), text, completed: false };
addOptimisticTodo(tempTodo);
await createTodoOnServer(text);
}
return (
<div>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
<form action={addTodo}>
<input name="text" placeholder="Add a todo..." />
<button type="submit">Add</button>
</form>
</div>
);
}
useFormStatus
Sometimes you need pending state inside a component that's nested within a form but doesn't have direct access to the action. useFormStatus from react-dom solves this by reading the status of the parent form:
Reusable submit button with useFormStatus
import { useFormStatus } from "react-dom";
function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? "Submitting..." : children}
</button>
);
}
Important:
useFormStatusmust be called from a component that is rendered inside a<form>. It reads the status of the parent form element, not any form passed as a prop.
When to Use What
Here's a quick decision guide:
- Simple form with server action → Use the
actionprop directly - Form that needs to show results/errors →
useActionState - Instant UI feedback →
useOptimistic - Submit button in a nested component →
useFormStatus - Non-form mutation →
useTransitionwith an async callback
