Actions and forms
Submit forms with Actions, show pending and error states with useFormStatus and useActionState, and update the UI instantly with useOptimistic.
On Day 3 you built the add-book form with controlled inputs: a state variable per field, an onChange on every input, and an onSubmit that called e.preventDefault(). Saving to a real server adds more: an isSaving flag, an error state, and resetting the fields when it worked.
React 19 calls a function that does this kind of work an Action. An Action is a function, often async, that runs inside a Transition. React keeps track of whether it's still running, so React can give you the pending state, the error handling and the form reset without extra code from you.
A form with an action
Pass a function to <form action={...}>. When the user submits, React calls it with a FormData object, the browser's built-in container for form values. You read each field with formData.get('fieldName'), where the name comes from the input's name attribute.
Compare this with Day 3. There's no useState for the fields, no onChange, and no preventDefault. The inputs are uncontrolled: the browser holds their values until you read them. When the Action finishes without throwing, React resets the form for you, so the inputs empty themselves.
You may wonder about the startTransition around setBooks. Everything an Action does before its first await is part of its Transition automatically. After an await, React loses track of that, so a state update after await needs its own startTransition to stay part of the Action. React's docs list this as a current limitation of JavaScript that should go away in the future. For now, follow the rule: state updates after await inside an Action go in startTransition.
Showing a pending button with useFormStatus
The form above gives no feedback while it saves. useFormStatus fixes that. It comes from react-dom and tells a component whether the <form> around it is submitting.
The button disables itself and changes its label while the save runs. Because SubmitButton finds its form by itself, you can reuse it in every form in your app.
Validation errors with useActionState
Most forms need to say "that's not right" sometimes. useActionState wraps your Action and remembers what it returned last time. That return value is your form's state: an error message, a success message, or anything else.
const [state, formAction, isPending] = useActionState(myAction, initialState);myAction(previousState, formData)is your Action. Note the extra first argument: it gets the previous state before the form data.statestarts asinitialState, and afterwards holds whatevermyActionlast returned.formActiongoes in<form action={formAction}>.isPendingistruewhile the Action runs.
Try submitting this form with an empty author, then with both fields filled.
Why does the action return submitted: formData on errors? React resets the form after every Action that doesn't throw, and returning an error message isn't throwing. Without submitted, the title you typed would vanish along with the mistake. Using it as the inputs' defaultValue means the reset puts your typing back. On success, submitted is null, so the fields clear.
Your Action is async function addBook(formData). You switch to useActionState(addBook, null) and now formData.get crashes. Why?
Instant feedback with useOptimistic
Waiting a second for every save feels slow, even with a pending button. Most saves succeed, so you can show the result optimistically: put the new book on screen right away, mark it as "saving", and let the real data replace it when the server answers. If the save fails, the optimistic version disappears and the list goes back to the truth.
useOptimistic gives you a temporary copy of some state that only exists while an Action is running:
const [optimisticBooks, addOptimisticBook] = useOptimistic(
books, // the real state
(currentBooks, newBook) => [...currentBooks, { ...newBook, pending: true }]
);While no Action is pending, optimisticBooks is the same as books. When you call addOptimisticBook(newBook) inside an Action, React runs your function and shows its result until the Action finishes. Then React shows books again, which by then includes the saved book.
Add a few books. Then add one whose title contains the word "fail" to see a failed save roll back.
The input clears and the book appears at the same moment you press the button. There are no "is it saving?" flags anywhere: the pending: true field only exists on the optimistic copy.
Deep diveWhy use a function instead of setting a value?
useOptimistic also accepts a direct value: setOptimistic(newList). The function form (current, newBook) => ... is safer. If the real books change while your save is pending, for example because another save finished first, React runs your function again on the new list, so both books show. A hard-coded value would hide the other update until your Action finished.
Make the status button optimistic
Each book has a Next status button. It calls a slow saveStatus function, and the badge only changes after the save finishes, which feels sluggish. Use useOptimistic so the badge changes the moment you click, and shows in italics while the save is pending.
Hints: the optimistic function receives the current list and { id, status }. Start an Action with startTransition(async () => { ... }) in the click handler.
Show solution
import { startTransition, useOptimistic, useState } from 'react';
import { saveStatus } from './api.js';
const next = { want: 'reading', reading: 'done', done: 'want' };
export default function App() {
const [books, setBooks] = useState([
{ id: '1', title: 'The Hobbit', status: 'done' },
{ id: '2', title: 'Dune', status: 'reading' },
{ id: '3', title: 'Circe', status: 'want' },
]);
const [optimisticBooks, setOptimisticStatus] = useOptimistic(
books,
(current, { id, status }) =>
current.map((b) => (b.id === id ? { ...b, status, saving: true } : b))
);
function handleNext(book) {
const status = next[book.status];
startTransition(async () => {
setOptimisticStatus({ id: book.id, status });
await saveStatus(book.id, status);
startTransition(() => {
setBooks((current) =>
current.map((b) => (b.id === book.id ? { ...b, status } : b))
);
});
});
}
return (
<ul>
{optimisticBooks.map((book) => (
<li key={book.id}>
{book.title}:{' '}
<b className={book.saving ? 'saving' : ''}>{book.status}</b>{' '}
<button onClick={() => handleNext(book)}>Next status</button>
</li>
))}
</ul>
);
}The list renders optimisticBooks, and the button receives the optimistic book. That means a second click while the first save is still pending moves on from the status you can see, not from the old one. The saving flag only exists on the optimistic copy, so it disappears by itself when the Action ends.
An optimistic save fails and the Action throws. What does the user see once the Action has finished?
Day 6 progress