skip to content

React 19 calls any async function run inside startTransition an Action. For a mutation with no form involved — a Delete button that calls an API and then updates state — what does running it as an Action buy you over an async onClick handler that manages its own isLoading state?

level: middleimportance: should knowfreq 45%

answer

  1. not only a form feature
  2. the await is inside the transition
  3. who clears the loading flag on throw
  4. the update stops being urgent
  5. one pending flag per hook call

basics

~20 s

React owns the pending state: the transition stays pending across every await until the async function settles, errors reach the nearest error boundary, optimistic values revert on their own, and the resulting updates are non-blocking rather than urgent.

solid answer

~50 s

React 19 lets `startTransition` take an async function, and it keeps the transition marked pending until that function's promise settles — not just until the first `await`. So `isPending` from `useTransition` covers the whole request, including the state update at the end, and you never write the `try/finally` that stops a hand-rolled `isLoading` flag getting stuck `true` when the call throws. Because the work is a transition, the updates it produces are non-urgent: React can keep the current UI interactive while they render, and it will not replace already-visible content with a Suspense fallback. An unhandled rejection inside the Action is surfaced to the nearest error boundary instead of vanishing into an unhandled promise. And any optimistic value you showed is tied to that transition's lifetime, so it is discarded automatically when the Action ends, however it ends.

code

jsx · 16 lines
jsx
function DeleteButton({ id, setItems }) {
  const [isPending, startTransition] = useTransition();

  function handleDelete() {
    startTransition(async () => {
      await deleteItem(id);
      setItems(items => items.filter(i => i.id !== id));
    });
  }

  return (
    <button onClick={handleDelete} disabled={isPending}>
      {isPending ? 'Deleting…' : 'Delete'}
    </button>
  );
}

go deeper

for a junior

Know that startTransition in React 19 accepts an async function, and that the isPending flag from useTransition covers the whole async call so you do not maintain your own loading boolean.

for a middle

Explain why the transition stays pending across awaits and what that gives you: no stuck loading flag when the call throws, a defined destination for the rejection, and a non-urgent update at the end.

for a senior

Show where the primitive stops. Talk through double submits, out-of-order resolution, and per-row pending state, and say when a mutation layer above React is the right call instead.

for a principal

Take a position on how mutations are expressed across the codebase — bare Actions versus a data layer — and on the cost of every team hand-rolling loading flags with subtly different failure behaviour.

## Actions are not a form feature The word *Action* in React 19 does not mean "form submission". It means an async function whose lifetime React is tracking, and there are two ways to create one: pass it to a `<form>`'s `action` prop, or call it inside `startTransition`. The second route is how mutations that have no form — a Delete button, a Like toggle, a drag-and-drop reorder — join the same model. ```jsx const [isPending, startTransition] = useTransition(); function handleDelete(id) { startTransition(async () => { await deleteItem(id); setItems(items => items.filter(i => i.id !== id)); }); } ``` The important change from React 18 is that `startTransition` now understands the `async` function. React keeps the transition pending until the returned promise settles, so `isPending` spans the network call *and* the state update that follows it. ## What the hand-rolled version actually costs The manual equivalent looks short: ```jsx const [isLoading, setIsLoading] = useState(false); async function handleDelete(id) { setIsLoading(true); await deleteItem(id); setItems(items => items.filter(i => i.id !== id)); setIsLoading(false); } ``` And it is wrong in ways that are only visible in production: - **The flag gets stuck.** If `deleteItem` rejects, the last line never runs and the button spins forever. The fix is a `try/finally`, which every such handler must repeat. - **The failure is silent.** An unhandled rejection in a plain event handler does not reach an error boundary — it becomes an unhandled promise rejection in the console. React routing the Action gives that rejection a defined destination. - **The post-await update is urgent.** State set after an `await` in a plain handler is an ordinary update. If it renders an expensive subtree, it blocks. Inside a transition it is non-urgent work React can interleave with user input. - **Nothing else can see it.** `isLoading` is local; a spinner in an unrelated part of the tree needs it as a prop or in context. ## Non-urgent is a real behavioural difference Marking the update as a transition is not merely a scheduling hint. Two visible consequences: 1. React can keep the page responsive while the transition's render happens, because it is allowed to interrupt that render for a more urgent update such as a keystroke. 2. If the transition's update causes a component to suspend, React will not replace already-visible content with the nearest Suspense fallback; it keeps showing the previous UI until the new one is ready. For a mutation that reveals fresh data, this is the difference between a smooth swap and a flash of skeleton. ## Where the boundaries are An Action is not a request manager. React does not deduplicate concurrent Actions, does not cancel an in-flight one when a second starts, does not retry, and does not cache. If a user clicks Delete three times you get three requests, and if two Actions finish out of order the later-resolving one wins. Guarding against that — disabling the control while `isPending`, or keying results — is still yours to design, and a data library on top may be the right answer for a mutation-heavy app. A second boundary: `isPending` belongs to the `useTransition` call that started it. Two independent mutations in one component tracked through the same hook share one pending flag, which is fine for a single control and wrong for a table where each row has its own Delete. ## The interview answer in one line The reason to use an Action is not that it is shorter — it is barely shorter. It is that pending state, error routing, update priority, and optimistic-value lifetime all become properties React derives from one fact it now knows: exactly when your async work starts and stops.

  • Does an Action cancel a previous one if the user triggers it twice?
    No. React does not deduplicate, cancel, or serialise Actions — two clicks produce two in-flight calls, and whichever resolves last wins the final state. The usual guard is to disable the control while `isPending`, or to key the response so a stale one is ignored. Anything more (request dedup, retries, cache invalidation) is a data-library concern, not a React primitive.
  • Can one useTransition hook track two different mutations independently?
    No — `isPending` reflects that hook's transitions collectively, so a shared hook gives a table of rows one shared spinner. If each row needs its own pending state, each row component needs its own `useTransition`, or the pending state needs to be keyed by row id in state you own.

saying these in an interview costs you the question

  • Thinks Actions only exist for form submissions
  • Says the transition ends at the first await
  • Claims a plain async onClick rejection hits an error boundary
  • Assumes React deduplicates or cancels concurrent Actions
  • Treats isPending as global rather than per hook call

context