useTransition

Marks state updates as non-blocking Transitions and reports whether one is still pending.

Signature
const [isPending, startTransition] = useTransition()
  • Added in React 18.0.0

Return value

[boolean, (action: () => void | Promise<void>) => void]An array with exactly two items: the isPending flag, which tells you whether there is a pending Transition, and the startTransition function, which lets you mark updates as a Transition. startTransition itself returns nothing and has a stable identity.

Errors

Throws an invalid-hook-call error if called outside a component or custom Hook; use the standalone startTransition in that case.

Changelog

  • PHP 19.0.0Async functions are supported as the action passed to startTransition; the Transition stays pending until the async work settles. State updates written after an await must still be wrapped in another startTransition to be marked as Transitions.
  • PHP 19.0.0Functions passed to startTransition are called Actions and integrate with form actions, useActionState, and useOptimistic.

useTransition gives you a way to mark a state update as non-urgent. React renders the update in the background at low priority, keeps the current screen interactive, and lets a later urgent update — a keystroke, a click — interrupt and restart it.

It returns isPending, true while the Transition is still rendering, and startTransition(action). React calls action immediately with no arguments and marks every set call made synchronously inside it as a Transition.

When to reach for it

  • Switching to a view that is expensive to render — a big table, a chart, a route — where the click currently freezes the page.
  • Submitting a form or running an async action, where isPending drives the disabled button and the label, replacing a hand-rolled loading flag.

What trips people up

Only updates scheduled during the synchronous run of action are part of the Transition. Anything in a setTimeout, or after an await, has lost the scope — wrap those in a second startTransition.

You also need access to the set function. If the slow value arrives as a prop or from a Hook you do not control, use useDeferredValue instead.

And a controlled text input cannot be driven by a Transition: its value would visibly lag the keystrokes.

Example

Try it yourself
Loading editor…
Loading React…

When to use it

  • Switch between tabs where one tab renders thousands of rows, without the click blocking the UI.
  • Disable a submit button and show "Saving…" from isPending while an async action runs, instead of tracking a loading flag by hand.
  • Navigate to a heavy route in a client-side router while the current page stays interactive and interruptible.
  • Apply an expensive filter or sort chosen from a dropdown while keeping the dropdown itself responsive.

Frequently asked questions

What is the difference between useTransition and useDeferredValue?

useTransition marks an update you trigger as non-urgent, so you need access to the set function. useDeferredValue defers a value you receive — a prop or a Hook result — when you cannot control where the update comes from.

Why is isPending stuck as false during my async work?

In React 18 startTransition did not track async actions, so pending ended as soon as the synchronous part finished. React 19 keeps the Transition pending across awaits, but state updates after an await must be wrapped in a second startTransition.

Can I use startTransition to update a controlled text input?

No. Transitions are interruptible, so the input's value would visibly lag behind the user's typing. Keep the input's state urgent and defer the expensive consumer with useDeferredValue instead.

More examples

An async action with a pending button

While the promise is unresolved the button reads "Saving…" and is disabled; afterwards "Saved:" updates to the new name. Clearing the input and submitting shows "Name is required" instead. The inner startTransition is the part people leave out — without it the post-await updates render as urgent, blocking work.

Example · jsx
import { useState, useTransition } from "react";

// No network here: a resolved promise stands in for the request.
function saveName(name) {
  return Promise.resolve(name.trim() ? null : "Name is required");
}

export default function NameForm() {
  const [name, setName] = useState("Ada");
  const [savedName, setSavedName] = useState("Ada");
  const [error, setError] = useState(null);
  const [isPending, startTransition] = useTransition();

  function handleSubmit() {
    startTransition(async () => {
      const message = await saveName(name);

      // After the await the Transition scope is gone — re-enter it.
      startTransition(() => {
        if (message) {
          setError(message);
        } else {
          setError(null);
          setSavedName(name);
        }
      });
    });
  }

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>
        {isPending ? "Saving…" : "Save"}
      </button>
      <p>Saved: {savedName}</p>
      {error ? <p>{error}</p> : null}
    </div>
  );
}

Why a controlled input cannot be a Transition

The rendered output is an input plus a line of results that trails it. The commented-out version is the mistake: because a Transition is interruptible, the input's own value would fall behind what the user typed. The fix keeps the input's state urgent and defers only the value handed to the expensive subtree.

Example · jsx
import { useDeferredValue, useState, useTransition } from "react";

function Results({ query }) {
  return <p>Results for "{query || "everything"}"</p>;
}

export default function Search() {
  const [text, setText] = useState("");
  const [, startTransition] = useTransition();

  // ❌ The input itself would lag behind the keystrokes:
  // <input
  //   value={text}
  //   onChange={(e) => startTransition(() => setText(e.target.value))}
  // />

  // ✅ Keep the input urgent and defer only the expensive consumer.
  const deferredText = useDeferredValue(text);

  return (
    <div>
      <input value={text} onChange={(e) => setText(e.target.value)} />
      <Results query={deferredText} />
    </div>
  );
}

Starting a Transition from outside a component

Clicking the buttons swaps the printed route. The standalone startTransition marks the update the same way, but there is no isPending flag — that is the trade-off when the Transition starts outside a component, in a store, a router, or an event handler on a non-React element.

Example · jsx
import { startTransition, useState } from "react";

// A tiny store living outside React.
let currentRoute = "/home";
const listeners = new Set();

function navigate(path) {
  // No Hook available here — use the standalone startTransition.
  startTransition(() => {
    currentRoute = path;
    listeners.forEach((fn) => fn(path));
  });
}

export default function Router() {
  const [route, setRoute] = useState(currentRoute);

  if (!listeners.has(setRoute)) listeners.add(setRoute);

  return (
    <div>
      <button onClick={() => navigate("/home")}>Home</button>{" "}
      <button onClick={() => navigate("/reports")}>Reports</button>
      <p>Route: {route}</p>
    </div>
  );
}

Discussion

  • Be the first to comment on this lesson.