Goal: keep the interface responsive during expensive updates with useTransition, and handle form submissions the React 19 way: form actions, useActionState, useFormStatus and useOptimistic.
Two moments in the tool library feel slow if you handle them naively: filtering a large catalogue while the member types, and waiting for the server after they press Reserve. React 19 has one idea that covers both: some updates are urgent (the letter you just typed must appear now), others can wait a moment (the filtered list, the server’s answer). This lesson shows how to tell React which is which, and how forms build on that idea. Next.js Server Actions (Lesson 36) use exactly the same form APIs, so this is time well spent.
In this lesson
- Urgent and non-urgent updates
- useTransition: keep typing smooth
- Actions: transitions that wait for the server
- Form actions and useActionState
- useFormStatus: a submit button that knows
- useOptimistic: show the result before the server confirms
- Which tool for which job
- Common mistakes
- Build it: the reservation form as a form action
- Check your understanding
Urgent and non-urgent updates
Normally every state update is treated the same: React renders the result straight away, and while it renders, the browser cannot respond to anything else. That is fine when rendering is quick. When one keystroke causes an expensive render, the page freezes until it is done, and the next keystroke has to wait.
Here is a catalogue whose rows are deliberately slow to render (each one wastes 40 milliseconds, standing in for a heavy component). Type “sander” quickly into the search box:
import { memo, useState } from 'react';
const TOOLS = [
{ id: 't1', name: 'Cordless drill', category: 'Power tools' },
{ id: 't2', name: 'Extension ladder', category: 'Ladders & access' },
{ id: 't3', name: 'Orbital sander', category: 'Power tools' },
{ id: 't4', name: 'Hedge trimmer', category: 'Garden' },
{ id: 't5', name: 'Pressure washer', category: 'Cleaning' },
{ id: 't6', name: 'Tile cutter', category: 'Hand tools' },
{ id: 't7', name: 'Stud finder', category: 'Hand tools' },
{ id: 't8', name: 'Lawn aerator', category: 'Garden' },
];
function SlowRow({ tool }) {
const start = performance.now();
while (performance.now() - start < 40) {
// Pretend this row is expensive to render.
}
return <li>{tool.name} ({tool.category})</li>;
}
const ToolList = memo(function ToolList({ query }) {
const q = query.toLowerCase();
const matches = TOOLS.filter((tool) => tool.name.toLowerCase().includes(q));
return <ul>{matches.map((tool) => <SlowRow key={tool.id} tool={tool} />)}</ul>;
});
function App() {
const [query, setQuery] = useState('');
return (
<div>
<label>
Search tools <input value={query} onChange={(e) => setQuery(e.target.value)} />
</label>
<ToolList query={query} />
</div>
);
}
The letters appear in jerks: each keystroke has to wait for the whole list to render before the input can show it. Lesson 13 covered the first fixes to try (render less, render cheaper). When the expensive work is genuinely needed, a transition is the next tool.
useTransition: keep typing smooth
useTransition returns two things: isPending, which is true while a transition is in progress, and startTransition, a function that marks the state updates inside it as non-urgent.
const [isPending, startTransition] = useTransition();
The trick is to split the state in two. The text in the input stays urgent, so it updates on every keystroke. The query that drives the expensive list is updated inside a transition:
import { memo, useState, useTransition } from 'react';
const TOOLS = [
{ id: 't1', name: 'Cordless drill', category: 'Power tools' },
{ id: 't2', name: 'Extension ladder', category: 'Ladders & access' },
{ id: 't3', name: 'Orbital sander', category: 'Power tools' },
{ id: 't4', name: 'Hedge trimmer', category: 'Garden' },
{ id: 't5', name: 'Pressure washer', category: 'Cleaning' },
{ id: 't6', name: 'Tile cutter', category: 'Hand tools' },
{ id: 't7', name: 'Stud finder', category: 'Hand tools' },
{ id: 't8', name: 'Lawn aerator', category: 'Garden' },
];
function SlowRow({ tool }) {
const start = performance.now();
while (performance.now() - start < 40) {
// Pretend this row is expensive to render.
}
return <li>{tool.name} ({tool.category})</li>;
}
const ToolList = memo(function ToolList({ query }) {
const q = query.toLowerCase();
const matches = TOOLS.filter((tool) => tool.name.toLowerCase().includes(q));
return <ul>{matches.map((tool) => <SlowRow key={tool.id} tool={tool} />)}</ul>;
});
function App() {
const [text, setText] = useState('');
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const next = e.target.value;
setText(next); // Urgent: the input shows the letter now.
startTransition(() => setQuery(next)); // Non-urgent: the list can wait.
}
return (
<div>
<label>
Search tools <input value={text} onChange={handleChange} />
</label>
{isPending && <span> Updating…</span>}
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<ToolList query={query} />
</div>
</div>
);
}
Type “sander” quickly again. The letters keep up much better, “Updating…” appears and the old list fades while the new one is prepared, then the list shows only the orbital sander. What changed:
- React renders transition updates in the background and pauses regularly to check for more urgent work. When you type another letter, it handles the input first, then starts the list again with the latest query, discarding the half-finished render for the old one.
isPendinglets you show that the list is out of date, instead of freezing.- The list is wrapped in
memo(Lesson 13). Without it, the urgent render fortextwould re-render the slow list too, with its old query, and you would be back to square one.
Transitions don’t make rendering any faster; they change what waits for what. One limit to remember: an input’s own value must never be set inside a transition, because the input has to reflect each keystroke immediately. That is why this example keeps two pieces of state.
If you cannot split the state (say the query arrives as a prop), useDeferredValue gives the same effect from the other side: const deferredQuery = useDeferredValue(query); returns a copy of the value that lags behind during busy moments. Pass deferredQuery to the memoised list and compare it with query to know when the list is stale.
Actions: transitions that wait for the server
In React 19, the function you pass to startTransition may also be async. React calls such a function an Action. isPending becomes true at the start and stays true until the whole function has finished, which is exactly the “saving…” state a button needs. A volunteer marks the ladder as returned:
import { useState, useTransition } from 'react';
function saveReturn(toolId) {
// Pretend server call.
return new Promise((resolve) => setTimeout(resolve, 800));
}
function ReturnButton() {
const [status, setStatus] = useState('on-loan');
const [isPending, startTransition] = useTransition();
function handleReturn() {
startTransition(async () => {
await saveReturn('t2');
startTransition(() => setStatus('available'));
});
}
return (
<div>
<p>Extension ladder: {status === 'on-loan' ? 'On loan' : 'Available'}</p>
<button onClick={handleReturn} disabled={isPending || status === 'available'}>
{isPending ? 'Saving…' : 'Mark as returned'}
</button>
</div>
);
}
Click the button: it shows “Saving…” and is disabled for a moment, then the status reads “Available”. Notice the second startTransition around setStatus. React can only see which updates belong to a transition while the function runs synchronously; after an await, that information is lost, so state updates after an await need their own startTransition. The React team describes this as a current limitation they plan to remove.
Two more properties of Actions are useful. If an Action throws, the error goes to the nearest error boundary (the exception mentioned in Lesson 14), so wrap failures you expect, like “server busy”, in try/catch and show a message instead. And if several transitions are running at the same time, React currently batches them together, so their results tend to appear at once rather than one by one.
Form actions and useActionState
The most common Action is submitting a form. In React 19 you can pass a function to a form’s action prop. When the form is submitted, React prevents the browser’s page reload, collects the fields into a FormData object, calls your function with it inside a transition, and resets the form when it finishes. There is no onSubmit, no preventDefault and no state per input.
On its own, an action function cannot report anything back. useActionState adds that: it remembers what your action returned last time.
const [state, formAction, isPending] = useActionState(action, initialState);
action(previousState, formData)receives the last returned state first and the form data second. Whatever it returns becomes the newstate.formActionis what you pass to<form action={formAction}>.isPendingistruewhile a submission is being handled. Submissions are queued: each waits for the previous one, sopreviousStateis always up to date.
Here is a small reservation form for the cordless drill. The browser’s own checks (required, min, max) are left out on purpose, so that you can see the “server” do the validation. Type a name, enter 9 days and press Reserve:
import { useActionState } from 'react';
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function reserveDrill(previousState, formData) {
await wait(800); // Pretend server.
const name = formData.get('name').trim();
const days = Number(formData.get('days'));
if (!name) return { error: 'Enter your name.', message: '' };
if (days < 1 || days > 7) return { error: 'Choose between 1 and 7 days.', message: '' };
return { error: '', message: `Reserved for ${name}, ${days} days.` };
}
function App() {
const [state, formAction, isPending] = useActionState(reserveDrill, { error: '', message: '' });
return (
<form action={formAction}>
<p><label>Your name <input name="name" /></label></p>
<p><label>Days <input name="days" type="number" defaultValue="1" /></label></p>
<button disabled={isPending}>{isPending ? 'Reserving…' : 'Reserve'}</button>
{state.error && <p role="alert">{state.error}</p>}
{state.message && <p role="status">{state.message}</p>}
</form>
);
}
The button shows “Reserving…”, then the error “Choose between 1 and 7 days.” appears. But look at the fields: the name is gone and the days are back to 1. The automatic reset happens after every submission, failed or not, so the member has to type everything again. The fix is to return the submitted values with the error and use them as each input’s defaultValue. After the reset, an uncontrolled input goes back to its defaultValue, which is now what the member typed. You will see that in the next example.
useFormStatus: a submit button that knows
The isPending from useActionState is fine when the button lives in the same component as the form. In a real app the submit button is a shared component used by many forms, and passing isPending down to it every time is tedious. useFormStatus, imported from react-dom rather than react, lets a component read the status of the form it is rendered inside:
import { useFormStatus } from 'react-dom';
const { pending, data, method, action } = useFormStatus();
The catch: it reports on the parent <form> of the component that calls it. Calling it in the component that renders the <form> itself does not work; pending stays false. Put it in a child component:
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
async function reserveDrill(previousState, formData) {
await new Promise((resolve) => setTimeout(resolve, 800)); // Pretend server.
const values = { name: formData.get('name').trim(), days: formData.get('days') };
const days = Number(values.days);
if (!values.name) return { error: 'Enter your name.', message: '', values };
if (!Number.isInteger(days) || days < 1 || days > 7) {
return { error: 'Choose between 1 and 7 days.', message: '', values };
}
return { error: '', message: `Reserved for ${values.name}, ${days} days.`, values: { name: '', days: '1' } };
}
function SubmitButton({ children }) {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Sending…' : children}</button>;
}
function App() {
const [state, formAction] = useActionState(reserveDrill, {
error: '', message: '', values: { name: '', days: '1' },
});
return (
<form action={formAction}>
<p><label>Your name <input name="name" defaultValue={state.values.name} /></label></p>
<p><label>Days <input name="days" type="number" defaultValue={state.values.days} /></label></p>
<SubmitButton>Reserve</SubmitButton>
{state.error && <p role="alert">{state.error}</p>}
{state.message && <p role="status">{state.message}</p>}
</form>
);
}
Try the same input as before (a name and 9 days). The button shows “Sending…”, the error appears, and this time both fields keep what you typed. Change the days to 3 and submit again: the confirmation appears and the form is cleared, because a successful submission returns empty values. SubmitButton can now go into any form in the app without extra props.
The validation here runs in the action because that is where it belongs. A real server must check every request anyway, since anyone can send one without your form (Lesson 37 covers server-side validation). Browser attributes such as required and checks in the component are still worth having, but only as a convenience that saves the member a round trip.
useOptimistic: show the result before the server confirms
Some actions almost always succeed, and waiting a second for confirmation makes the app feel sluggish. useOptimistic shows the expected result straight away, while the action runs, and drops it automatically when the action ends:
const [shownValue, addOptimistic] = useOptimistic(realValue, (current, update) => next);
While no action is running, shownValue simply equals realValue. Inside an action, calling addOptimistic(update) runs your update function and shows its result immediately. When the action finishes, React goes back to realValue. If the action succeeded and you updated the real state, the two agree and nothing visibly changes. If it failed and the real state was never updated, the screen falls back to the truth. Click Reserve on the cordless drill, then try the stud finder:
import { startTransition, useOptimistic, useState } from 'react';
const TOOLS = [
{ id: 't1', name: 'Cordless drill' },
{ id: 't3', name: 'Orbital sander' },
{ id: 't7', name: 'Stud finder' },
];
async function reserveOnServer(toolId) {
await new Promise((resolve) => setTimeout(resolve, 1000)); // Pretend server.
if (toolId === 't7') throw new Error('Someone else reserved the stud finder first.');
}
function App() {
const [reservedIds, setReservedIds] = useState([]);
const [shownIds, addReserved] = useOptimistic(reservedIds, (ids, toolId) => [...ids, toolId]);
const [error, setError] = useState('');
async function reserveAction(formData) {
const toolId = formData.get('toolId');
addReserved(toolId);
try {
await reserveOnServer(toolId);
startTransition(() => setReservedIds((ids) => [...ids, toolId]));
} catch (e) {
setError(e.message);
}
}
return (
<div>
<ul>
{TOOLS.map((tool) => (
<li key={tool.id}>
{tool.name}: {shownIds.includes(tool.id) ? 'Reserved' : 'Available'}
{shownIds.includes(tool.id) && !reservedIds.includes(tool.id) && ' (saving…)'}
{!shownIds.includes(tool.id) && (
<form action={reserveAction} style={{ display: 'inline' }}>
<input type="hidden" name="toolId" value={tool.id} />{' '}
<button>Reserve</button>
</form>
)}
</li>
))}
</ul>
{error && <p role="alert">{error}</p>}
</div>
);
}
The drill switches to “Reserved (saving…)” the moment you click, and a second later to “Reserved”. The stud finder also shows “Reserved (saving…)” at once, but the pretend server refuses it: it goes back to “Available” and the message explains why. You did not write any code to undo the optimistic change; it disappeared because the action ended without the real state including the stud finder.
Each tool has its own small form with a hidden toolId field, so the same action serves every row. Two rules: call the optimistic setter only inside an Action (a form action or startTransition; outside one, React warns in the console), and always tell the member when an optimistic change is rolled back. An item that silently flips back looks like a bug.
Which tool for which job
- An expensive render triggered by typing or clicking: first make it cheaper (Lesson 13); if it still lags,
useTransitionoruseDeferredValue. - A button that calls the server:
useTransitionwith an async function, usingisPendingfor the busy state. - A form that submits:
<form action>withuseActionStatefor the result (errors, confirmation, submitted values). - A submit button or field that should know the form is busy:
useFormStatusin a child component of the form. - Instant feedback for an action that usually succeeds:
useOptimistic, with a clear message when it fails. - Showing a placeholder while code or data loads: Suspense (Lesson 14), not any of the above.
Common mistakes
- Setting an input’s own value inside a transition. The input stops reflecting keystrokes reliably. Keep the input’s state urgent and move only the expensive, derived update into the transition.
- **Calling
useFormStatusin the component that renders the<form>.**pendingis alwaysfalse, so the button never disables. In review, check that the hook is in a component rendered *inside* the form. - Losing the member’s input after a failed submission. The form resets after every action. Return the submitted values in the state and use them as
defaultValue, or the member retypes everything after each error. - **State updates after
awaitoutside a transition.** React no longer knows they belong to the Action, so they are not handled as part of it and the pending and optimistic states can get out of step with the real data. Wrap updates after anawaitinstartTransition, as the examples do. - Trusting validation that only runs in the browser. It is a convenience, not protection. The server (here, the action) must check everything again.
Build it: the reservation form as a form action
The pretend server function submitReservation below already checks the business rules from Lesson 8: a name is required, the start date cannot be in the past, the end date is on or after the start date, and the loan lasts at most 7 days. It returns { errors, values, message }. Turn the form into a form action:
- Connect
submitReservationwithuseActionStateand the form’sactionprop (remove the placeholderonSubmit). - Show each field’s error under it, linked with
aria-describedby, and mark the field witharia-invalid. - Keep what the member typed after a failed submission.
- Replace the button with a
SubmitButtonthat usesuseFormStatusand shows “Reserving…” while the request runs. - Show the confirmation message after a successful submission.
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
const tool = { id: 't1', name: 'Cordless drill', maxLoanDays: 7 };
const emptyValues = { name: '', start: '', end: '' };
function todayISO() {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
return now.toISOString().slice(0, 10);
}
// Pretend server: checks the business rules, then saves.
async function submitReservation(previousState, formData) {
await new Promise((resolve) => setTimeout(resolve, 800));
const values = { name: formData.get('name').trim(), start: formData.get('start'), end: formData.get('end') };
const errors = {};
if (!values.name) errors.name = 'Enter your name.';
if (!values.start || values.start < todayISO()) errors.start = 'Choose a start date from today onwards.';
if (!values.end || values.end < values.start) {
errors.end = 'The end date must be on or after the start date.';
} else if ((Date.parse(values.end) - Date.parse(values.start)) / 86400000 + 1 > tool.maxLoanDays) {
errors.end = `The longest loan for this tool is ${tool.maxLoanDays} days.`;
}
if (Object.keys(errors).length > 0) return { errors, values, message: '' };
return { errors: {}, values: emptyValues, message: `Reserved from ${values.start} to ${values.end}. See you then, ${values.name}.` };
}
function ReservationForm() {
// Placeholder: replace onSubmit with a form action.
return (
<form onSubmit={(e) => e.preventDefault()}>
<h2>Reserve the {tool.name.toLowerCase()}</h2>
<p><label htmlFor="name">Your name</label><br /><input id="name" name="name" /></p>
<p><label htmlFor="start">Start date</label><br /><input id="start" name="start" type="date" /></p>
<p><label htmlFor="end">End date</label><br /><input id="end" name="end" type="date" /></p>
<button>Reserve</button>
</form>
);
}
Hint: start with useActionState(submitReservation, { errors: {}, values: emptyValues, message: '' }). A small Field component that takes name, label, type and the state avoids writing the label, input, error and ARIA attributes three times.
Show a sample solution
import { useActionState } from 'react';
import { useFormStatus } from 'react-dom';
const tool = { id: 't1', name: 'Cordless drill', maxLoanDays: 7 };
const emptyValues = { name: '', start: '', end: '' };
function todayISO() {
const now = new Date();
now.setMinutes(now.getMinutes() - now.getTimezoneOffset());
return now.toISOString().slice(0, 10);
}
// Pretend server: checks the business rules, then saves.
async function submitReservation(previousState, formData) {
await new Promise((resolve) => setTimeout(resolve, 800));
const values = { name: formData.get('name').trim(), start: formData.get('start'), end: formData.get('end') };
const errors = {};
if (!values.name) errors.name = 'Enter your name.';
if (!values.start || values.start < todayISO()) errors.start = 'Choose a start date from today onwards.';
if (!values.end || values.end < values.start) {
errors.end = 'The end date must be on or after the start date.';
} else if ((Date.parse(values.end) - Date.parse(values.start)) / 86400000 + 1 > tool.maxLoanDays) {
errors.end = `The longest loan for this tool is ${tool.maxLoanDays} days.`;
}
if (Object.keys(errors).length > 0) return { errors, values, message: '' };
return { errors: {}, values: emptyValues, message: `Reserved from ${values.start} to ${values.end}. See you then, ${values.name}.` };
}
function Field({ name, label, type = 'text', state }) {
const error = state.errors[name];
return (
<p>
<label htmlFor={name}>{label}</label><br />
<input id={name} name={name} type={type} defaultValue={state.values[name]}
aria-invalid={error ? true : undefined} aria-describedby={error ? `${name}-error` : undefined} />
{error && <><br /><span id={`${name}-error`}>{error}</span></>}
</p>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Reserving…' : 'Reserve'}</button>;
}
function ReservationForm() {
const [state, formAction] = useActionState(submitReservation, { errors: {}, values: emptyValues, message: '' });
return (
<form action={formAction}>
<h2>Reserve the {tool.name.toLowerCase()}</h2>
<Field name="name" label="Your name" state={state} />
<Field name="start" label="Start date" type="date" state={state} />
<Field name="end" label="End date" type="date" state={state} />
<SubmitButton />
{state.message && <p role="status">{state.message}</p>}
</form>
);
}
Press Reserve with the form empty: after “Reserving…”, each field shows its own error. Fill in a name, today as the start date and an end date ten days later: the name and dates stay, and only the end date reports the 7-day limit. Change the end date to three days later and submit again: the confirmation appears and the form clears. The component holds no state of its own besides what useActionState returns; the inputs are uncontrolled and the action receives everything through FormData. Because the errors live in one returned object, the form always shows the result of the latest submission and nothing older.
Check your understanding
Answer each question in a sentence or two before opening the answer.
1. In the search example, why does the input keep its own state instead of reading the query that is updated in the transition?
An input must show each keystroke immediately, and transition updates can be delayed or restarted. If the input’s value came from transition state, it would lag behind or lose characters. So the typed text stays urgent state, and only the expensive list reads the query that is set inside startTransition.
2. A colleague’s submit button never disables while the form is sending. The component calls useFormStatus() and renders the <form> and the button. What is wrong, and how would you check?
useFormStatus reports on the parent form of the component that calls it, and here the form is rendered by that same component, so pending is always false. Move the button into its own component rendered inside the form. To check, add a delay to the action (or use the browser’s network throttling) and confirm the button shows its busy label and is disabled while the request runs.
3. With useOptimistic, a tool shows “Reserved” and then flips back to “Available” a second later with no message. What is probably happening, and how would you make it trustworthy?
The action finished without the real state including that tool, so React dropped the optimistic value. Either the server refused the request and the error was swallowed, or the real state was never updated after a successful call. Check the action: update the real state after success (inside startTransition after the await), and in the catch set an error message the member can see. Test both paths by making the pretend server fail on purpose.
Key takeaways
- Transitions mark updates as non-urgent: React keeps urgent work such as typing responsive and can restart a slow background render with newer data.
- An async function passed to
startTransitionis an Action;isPendingstays true until it finishes. Wrap state updates afterawaitin anotherstartTransition. <form action={fn}>hands React the submission: it passesFormData, runs it in a transition and resets the form afterwards.useActionStatekeeps the action’s latest result (errors, values, messages). Return the submitted values to refill fields after an error.useFormStatus(fromreact-dom) works only in a component inside the form.useOptimisticshows the expected result during an action and falls back to the real state when it ends.
Priority: must understand. These APIs are how forms and mutations work in React 19 and in Next.js Server Actions, so you should be able to build a form with them and review someone else’s. For exact signatures, see useTransition and useActionState in the React documentation.
