Modern Web · React

React 19 useTransition — A Field Guide

How React 19 lets startTransition wrap an async function and track its pending state until it resolves, what that has to do with Actions, and when to reach for useDeferredValue instead.

John Kihiu12 min read

useTransition has been around since React 18, but React 19 gives it a capability that changes how much of it you actually reach for directly: the function you pass to startTransition can now be async, and React tracks the pending state across the whole await chain, not just the synchronous part. That single change is what Actions are built on top of.

What marks an update as non-urgent

A transition tells React "this state update can be deferred if something more urgent comes in." The classic example is a large list re-filtering as you type: the input itself needs to update every keystroke, urgently, but the filtered list re-render can lag a frame or two behind without the user noticing, because React keeps the previous list visible (rather than blanking it) while the new one is computed.

TSX · BASIC useTransition
import { useState, useTransition } from 'react';

function FilterableList({ items }: { items: string[] }) {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();
  const [filtered, setFiltered] = useState(items);

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setQuery(value); // urgent: keep the input responsive

    startTransition(() => {
      // non-urgent: can be interrupted by the next keystroke
      setFiltered(items.filter((i) => i.includes(value)));
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      <ul style={{ opacity: isPending ? 0.6 : 1 }}>
        {filtered.map((i) => <li key={i}>{i}</li>)}
      </ul>
    </>
  );
}

Async transitions: the React 19 addition

Before React 19, the function passed to startTransition had to be synchronous — you could kick off an async operation inside it, but isPending would flip back to false as soon as the synchronous part finished, not when your async work actually completed. React 19 lets the callback be an async function, and isPending now stays true for the entire duration of the await chain, flipping back only once the promise the function returns has settled.

TSX · ASYNC TRANSITION
import { useState, useTransition } from 'react';

function SaveButton({ draft }: { draft: string }) {
  const [isPending, startTransition] = useTransition();
  const [error, setError] = useState<string | null>(null);

  function handleSave() {
    startTransition(async () => {
      try {
        await saveDraft(draft); // isPending stays true until this settles
        setError(null);
      } catch (e) {
        setError('Save failed — try again');
      }
    });
  }

  return (
    <button onClick={handleSave} disabled={isPending}>
      {isPending ? 'Saving…' : 'Save'}
    </button>
  );
}

This is exactly the mechanism Actions (useActionState, form actions) are built on: an Action is, under the hood, an async transition wired up to a form or a state updater, giving you isPending for free without a manually-managed loading boolean and the bugs that come with forgetting to reset it in a finally block.

Errors thrown inside an async transition don't propagate automatically

If an async function passed to startTransition throws and you don't catch it, React reports it as an unhandled error, but your UI has no built-in way to show it — there's no automatic error boundary integration the way Suspense triggers one. Catch it yourself and store it in state, as in the example above.

useTransition vs useDeferredValue

Both exist to keep the UI responsive during an expensive re-render, but they solve it from opposite ends. useTransition wraps the state update you control — you call startTransition around the specific setState call you want deferred. useDeferredValue instead wraps a value you're already receiving, typically a prop or a piece of state you don't own the setter for, and gives you back a lagging copy of it that updates once React has spare capacity.

Reach for useTransition when you're triggering the update yourself (a click handler, a form submit, an input's onChange). Reach for useDeferredValue when the expensive value is coming from somewhere else — a parent's prop, a global store, a search query living in the URL — and you just want to render a stale-but-current version of it without blocking on the latest one.

Wrapping up

The core idea behind transitions hasn't moved: mark a re-render as interruptible so a keystroke or click never waits behind an expensive update. What React 19 adds is letting that non-urgent window span an actual async operation, with isPending tracking the whole thing — which is the missing piece that makes Actions possible without hand-rolled loading state. If you're deciding between the two transition-adjacent hooks, the question is who owns the state: your own event handler reaches for useTransition, a value you merely consume reaches for useDeferredValue.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.