React 19 all 2 written
Actions and useActionState, optimistic updates and useFormStatus: forms that talk to the server.
28 Actions and useActionState
Until now, a form in React meant an onSubmit handler that calls
event.preventDefault(), reads the fields and manages "is it sending?" with state. React 19
moves that into the framework: give the form a function as its action, and React reads the
fields for you, runs the function, and tells you while it is still running.
A function as the form's action
useActionState(action, initialState) returns three things:
the current state, a formAction to give to the form, and
isPending. The action receives the previous state and the form's
FormData, and what it returns becomes the new state. It may be async: the form stays
"pending" until the returned promise settles. The panel holds that promise open so it can read the
page in the middle.
action.jsx
import { useActionState, act } from 'react';
import { createRoot } from 'react-dom/client';
let calls = 0;
let release;
async function save(previous, formData) {
calls += 1;
const title = formData.get('title');
await new Promise((resolve) => {
release = resolve;
});
return { saved: title };
}
function NewBook() {
const [state, formAction, isPending] = useActionState(save, { saved: null });
return (
<form action={formAction}>
<input name="title" defaultValue="Dune" />
<button>{isPending ? 'saving' : 'save'}</button>
<p>{state.saved ? 'saved ' + state.saved : 'nothing yet'}</p>
</form>
);
}
const root = createRoot(stage);
await act(async () => root.render(<NewBook />));
const button = () => stage.querySelector('button').textContent;
const message = () => stage.querySelector('p').textContent;
const form = stage.querySelector('form');
const steps = [[button(), message()]];
await act(async () => form.requestSubmit());
steps.push([button(), message()]);
await act(async () => release());
steps.push([button(), message()]);
console.log(steps, calls);
check('before: ready and empty', steps[0], ['save', 'nothing yet']);
check('mid-flight: pending, nothing saved yet', steps[1], ['saving', 'nothing yet']);
check('after: the action returned the title it read from the form', steps[2], ['save', 'saved Dune']);
check('the action ran once', calls, 1);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node action.jsx
[ [ 'save', 'nothing yet' ], [ 'saving', 'nothing yet' ], [ 'save', 'saved Dune' ] ] 1 ok before: ready and empty ok mid-flight: pending, nothing saved yet ok after: the action returned the title it read from the form ok the action ran once
Return an error, do not throw it
An action can fail in two ways. A thing the user got wrong, an empty title, is not an
exception, it is a normal result, so return it as state and show it. The action below validates
and returns { error }; the form simply renders it. (An action that throws is
handled like any render error: lesson 27's boundary.)
validate.jsx
import { useActionState, act } from 'react';
import { createRoot } from 'react-dom/client';
let calls = 0;
async function save(previous, formData) {
calls += 1;
const title = String(formData.get('title')).trim();
if (!title) return { error: 'Title is required', saved: null };
return { error: '', saved: title };
}
function NewBook({ initial }) {
const [state, formAction] = useActionState(save, { error: '', saved: null });
return (
<form action={formAction}>
<input name="title" defaultValue={initial} />
<button>save</button>
<p role="status">{state.error || (state.saved ? 'saved ' + state.saved : '')}</p>
</form>
);
}
async function submit(initial) {
const root = createRoot(stage);
await act(async () => root.render(<NewBook initial={initial} />));
await act(async () => stage.querySelector('form').requestSubmit());
const text = stage.querySelector('p').textContent;
await act(async () => root.unmount());
return text;
}
const empty = await submit('');
const filled = await submit('Emma');
console.log(empty, '|', filled, calls);
check('an empty title came back as an error message', empty, 'Title is required');
check('a title came back as saved', filled, 'saved Emma');
check('the action ran once per submit', calls, 2);
What node printed (React 19.2.7, development build), when the build ran it
node validate.jsx
Title is required | saved Emma 2 ok an empty title came back as an error message ok a title came back as saved ok the action ran once per submit
The attribute React writes
Browsers know how to submit a form to a URL, not to a function. So that nothing runs the wrong
way if the form is ever submitted natively, for example before the page's JavaScript has loaded,
React gives the action attribute a javascript: URL that throws an error
with an explanation. Once React is running it intercepts the submit and calls your function. This is
the one place in the course where a form's own markup is printed; the panel cuts it to its first 60
characters.
attribute.jsx
import { useActionState, act } from 'react';
import { createRoot } from 'react-dom/client';
function Plain() {
const [state, formAction] = useActionState(async () => 'done', 'start');
return (
<form action={formAction}>
<button>{state}</button>
</form>
);
}
const root = createRoot(stage);
await act(async () => root.render(<Plain />));
const attribute = stage.querySelector('form').getAttribute('action');
console.log(attribute.slice(0, 60));
check('it is a javascript: URL that throws', attribute.startsWith('javascript:throw new Error('), true);
check('the first 60 characters are all we print', attribute.slice(0, 60).length, 60);
check('it is in the markup React wrote', stage.innerHTML.includes('action="javascript:'), true);
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node attribute.jsx
javascript:throw new Error('A React form was unexpectedly su
ok it is a javascript: URL that throws
ok the first 60 characters are all we print
ok it is in the markup React wrote
Promises, which every async action returns, are covered in the Node.js course: promises and async/await.
<form action={fn}>callsfn(formData)when the form is submitted.useActionState(action, initial)gives[state, formAction, isPending].- The action gets the previous state and the
FormData; its return value is the next state. - Return expected failures as state. Throw only for the unexpected.
- React writes a
javascript:URL intoactionas a guard against a native submit.
29 useOptimistic and useFormStatus
A real save takes time, and an interface that waits for it feels slow. The
trick is to show the result at once and be ready to take it back. React 19 has a pair of tools
for it: useOptimistic for the list you show, and useFormStatus for the
button that reflects whether the form is busy.
Three moments of one save
useOptimistic(state, update) returns a version of state and a function,
here addOptimistic, that adds a temporary change. The action calls it first, so the
row appears at once, marked (saving), and then it waits for the pretend server. When the
action finishes, the temporary change is dropped and the real state, which now holds the new book,
takes its place. useFormStatus must be called from a component inside the
form, which is why the button is its own component. The panel reads the list and the button
three times and prints each as one line.
optimistic.jsx
import { useActionState, useOptimistic, act } from 'react';
import { useFormStatus } from 'react-dom';
import { createRoot } from 'react-dom/client';
let release;
function Status() {
const { pending } = useFormStatus();
return <button>{pending ? 'saving' : 'add'}</button>;
}
function Books() {
const [books, formAction] = useActionState(async (previous, formData) => {
const title = formData.get('title');
addOptimistic(title);
await new Promise((resolve) => {
release = resolve;
});
return [...previous, title];
}, ['Dune']);
const [shown, addOptimistic] = useOptimistic(books, (current, title) => [...current, title + ' (saving)']);
return (
<div>
<form action={formAction}>
<input name="title" defaultValue="Emma" />
<Status />
</form>
<ul>
{shown.map((title) => (
<li key={title}>{title}</li>
))}
</ul>
</div>
);
}
const root = createRoot(stage);
await act(async () => root.render(<Books />));
const line = (n) => n + ' ' + stage.querySelector('ul').innerHTML + ' ' + stage.querySelector('button').textContent;
const lines = [line(1)];
await act(async () => stage.querySelector('form').requestSubmit());
lines.push(line(2));
await act(async () => release());
lines.push(line(3));
lines.forEach((text) => console.log(text));
check('1: before the save', lines[0], '1 <li>Dune</li> add');
check('2: the new row is there at once, and the button is busy', lines[1], '2 <li>Dune</li><li>Emma (saving)</li> saving');
check('3: the real list replaced the temporary one', lines[2], '3 <li>Dune</li><li>Emma</li> add');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node optimistic.jsx
1 <li>Dune</li> add 2 <li>Dune</li><li>Emma (saving)</li> saving 3 <li>Dune</li><li>Emma</li> add ok 1: before the save ok 2: the new row is there at once, and the button is busy ok 3: the real list replaced the temporary one
When the save fails
The temporary row only lives while the action runs. When the action ends React throws it away and shows whatever the real state is. If the action failed and returned the old list, the row vanishes, which is exactly right: the book was not saved, and the page says so. The action below returns an error alongside the unchanged list.
rollback.jsx
import { useActionState, useOptimistic, act } from 'react';
import { createRoot } from 'react-dom/client';
let release;
function Books() {
const [state, formAction] = useActionState(
async (previous, formData) => {
const title = formData.get('title');
addOptimistic(title);
await new Promise((resolve) => {
release = resolve;
});
return { books: previous.books, error: 'Could not save ' + title };
},
{ books: ['Dune'], error: '' },
);
const [shown, addOptimistic] = useOptimistic(state.books, (current, title) => [...current, title + ' (saving)']);
return (
<div>
<form action={formAction}>
<input name="title" defaultValue="Emma" />
<button>add</button>
</form>
<ul>
{shown.map((title) => (
<li key={title}>{title}</li>
))}
</ul>
<p>{state.error}</p>
</div>
);
}
const root = createRoot(stage);
await act(async () => root.render(<Books />));
const list = () => stage.querySelector('ul').innerHTML;
const original = list();
await act(async () => stage.querySelector('form').requestSubmit());
const during = list();
await act(async () => release());
const after = list();
const error = stage.querySelector('p').textContent;
console.log(during, after, error);
check('while saving, the temporary row was shown', during, '<li>Dune</li><li>Emma (saving)</li>');
check('after the failure, the list is the original again', after, original);
check('and the page says what happened', error, 'Could not save Emma');
await act(async () => root.unmount());
What node printed (React 19.2.7, development build), when the build ran it
node rollback.jsx
<li>Dune</li><li>Emma (saving)</li> <li>Dune</li> Could not save Emma ok while saving, the temporary row was shown ok after the failure, the list is the original again ok and the page says what happened
Optimism is a promise you have to keep honest. Show the expected result only when failure is rare, and always give the failure somewhere to be seen, as the error text above does. A row that quietly disappears with no explanation is a bug the user will report as lost data.
useOptimistic(state, update)shows a temporary version of state while an action runs.- The temporary change is dropped when the action ends; the real state shows after.
useFormStatus()reads whether the enclosing form is pending, from a component inside it.- On failure return the old state and an error; the optimistic row goes away by itself.