Before React 19, every mutation in a component followed the same ritual: a useState for pending, a useState for error, a try/catch around the fetch, and a finally to reset the pending flag. Actions collapse that ritual into the framework itself. An Action is just an async function passed somewhere React expects one — a form's action prop, or a transition started with startTransition — and React takes over tracking whether it's in flight, whether it failed, and what the optimistic UI should look like in between.
What an Action actually is
There's no new syntax to learn: an Action is any function, sync or async, that you hand to startTransition or to one of the APIs built on top of it. What makes it an "Action" rather than an ordinary event handler is that React wraps its execution in a transition automatically, which means state updates inside it are marked non-urgent and the UI stays responsive while the mutation is pending — no manual isPending flag required, because React tracks that for you.
import { useActionState } from 'react';
type State = { error: string | null; ok: boolean };
async function renameProject(prev: State, formData: FormData): Promise {
const name = String(formData.get('name') || '').trim();
if (name.length < 3) {
return { error: 'Name must be at least 3 characters', ok: false };
}
const res = await fetch('/api/projects/rename', {
method: 'POST',
body: JSON.stringify({ name }),
});
if (!res.ok) return { error: 'Server rejected the rename', ok: false };
return { error: null, ok: true };
}
export function RenameForm() {
const [state, formAction, isPending] = useActionState(renameProject, {
error: null,
ok: false,
});
return (
);
}
useActionState takes the action function and an initial state, and gives back the latest state, a wrapped action to pass to the form, and a pending boolean. React re-runs renameProject each time the form submits, threads the previous return value in as prev, and re-renders with whatever the function returns — no separate state variable to keep in sync by hand.
Actions aren't limited to forms
The pattern isn't tied to <form> at all. Any button click, drag handler, or keyboard shortcut can trigger the same async-transition behavior by wrapping the call in startTransition directly. This is the piece that gets missed when people describe Actions as "the new form API" — a "like" button, an inline delete, or a reorder-on-drop handler benefit from the same pending/error handling without a form element anywhere nearby.
import { useState, useTransition } from 'react';
export function LikeButton({ postId, initialLiked }: { postId: string; initialLiked: boolean }) {
const [liked, setLiked] = useState(initialLiked);
const [isPending, startTransition] = useTransition();
function toggleLike() {
const next = !liked;
startTransition(async () => {
setLiked(next); // optimistic
const res = await fetch(`/api/posts/${postId}/like`, { method: 'POST' });
if (!res.ok) setLiked(!next); // roll back on failure
});
}
return (
);
}
For the common case of "show the new value immediately, revert if the server disagrees," useOptimistic pairs naturally with Actions — it manages the temporary state and the reversion for you instead of the manual setLiked(!next) shown above.
Error handling without manual try/catch
If an Action throws instead of returning an error value, React doesn't let it vanish silently or crash the whole tree. Uncaught errors from Actions propagate to the nearest error boundary the same way a render error would, which means you can let a genuinely exceptional case (network layer down, malformed response) throw, and reserve the returned-state pattern in useActionState for expected, user-facing validation failures. Mixing the two deliberately — throw for "this shouldn't happen," return for "the user needs to fix a field" — keeps the component body free of nested try/catch blocks.
Inside an Action, a thrown error already has somewhere to go. Swallowing it in a local try/catch just to set an error state is usually reproducing what useActionState's return-value handles better, and it hides genuine bugs from your error boundary and error reporting.
Wrapping up
Actions aren't a new data-fetching library or a replacement for React Query — they're React finally acknowledging that "call an async function, track its pending/error state, maybe roll back on failure" is common enough to deserve first-class support. Whether the trigger is a form submission or a button click, the shape is the same: wrap the mutation in a transition, let useActionState or useOptimistic hold the derived state, and let errors surface through the boundary instead of a hand-rolled catch block.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.