useActionState(action, initialState) wraps an action so React 7,897 tracks its result and its pending flag for you, returning [state, formAction, isPending]. Your action is called with (previousState, formData) and returns the next state — a reducer, except that this one may be async and may perform side effects. Pass formAction to the form's action prop; state is whatever the last call returned, which makes it the natural home for a validation error or a success message.
<div id="root"></div>
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">{ "imports": { "react": "https://esm.sh/react@19.3.0",
"react-dom/client": "https://esm.sh/react-dom@19.3.0/client" } }</script>
<script type="text/babel" data-type="module">
import React, { useActionState } from 'react';
import { createRoot } from 'react-dom/client';
async function updateName(previous, formData) {
const name = formData.get('name').trim();
await new Promise(r => setTimeout(r, 400)); // simulate a slow server
if (!name) return { ...previous, error: 'Name cannot be empty.' };
return { name, error: null };
}
const initial = { name: 'Guest', error: null };
function App() {
const [state, formAction, isPending] = useActionState(updateName, initial);
return (
<form action={formAction} style={{ font: '15px system-ui' }}>
<p>Current name: <b>{state.name}</b></p>
{state.error && <p style={{ color: 'crimson' }}>{state.error}</p>}
<input name="name" disabled={isPending} />
<button disabled={isPending}>{isPending ? 'Saving...' : 'Save'}</button>
</form>
);
}
createRoot(document.getElementById('root')).render(<App />);
setTimeout(() => { document.querySelector('input').value = 'Ada';
document.querySelector('button').click(); }, 300);
</script>
Returning an error object instead of throwing is the pattern to learn: a thrown error reaches the nearest error boundary and takes the form down with it, while a returned { error } renders next to the field. Submit again while a call is in flight and React queues the calls, passing each one the previous result. React 19 canaries named this Hook useFormState and exported it from react-dom; that is the same Hook under its old name.