React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

concurrencytransitionsperformancepending-statehooks

useTransition Hook

Mark state updates as non-urgent so they don't block user input.

Recipe

Quick-reference recipe card — copy-paste ready.

const [isPending, startTransition] = useTransition();
 
// Wrap a slow state update
startTransition(() => {
  setSearchResults(filterLargeList(query));
});
 
// Show pending state
{isPending && <Spinner />}

When to reach for this: A state update causes expensive re-rendering (filtering a large list, switching tabs with heavy content) and you want the UI to stay responsive during the update.

Working Example

"use client";
 
import { useState, useTransition } from "react";
 
const ALL_ITEMS = Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  name: `Item ${i}`,
  category: ["Electronics", "Books", "Clothing", "Food"][i % 4],
}));
 
export function FilterableList() {
  const [query, setQuery] = useState("");
  const [filtered, setFiltered] = useState(ALL_ITEMS);
  const [isPending, startTransition] = useTransition();
 
  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setQuery(value); // Urgent: update the input immediately
 
    startTransition(() => {
      // Non-urgent: filter the large list
      setFiltered(
        ALL_ITEMS.filter((item) =>
          item.name.toLowerCase().includes(value.toLowerCase())
        )
      );
    });
  }
 
  return (
    <div className="space-y-3">
      <input
        value={query}
        onChange={handleChange}
        placeholder="Search 10,000 items..."
        className="border rounded px-3 py-2 w-full"
      />
      {isPending && <p className="text-sm text-gray-500">Updating...</p>}
      <ul className="max-h-64 overflow-y-auto text-sm">
        {filtered.slice(0, 100).map((item) => (
          <li key={item.id} className="py-0.5">
            {item.name} — {item.category}
          </li>
        ))}
      </ul>
      <p className="text-xs text-gray-400">{filtered.length} results</p>
    </div>
  );
}

What this demonstrates:

  • Typing in the input is instant because setQuery is outside startTransition
  • The expensive filtering runs in a transition, so React can interrupt it if the user types again
  • isPending shows a loading indicator while the transition is in progress
  • The UI never freezes, even with 10,000 items

Deep Dive

How It Works

  • startTransition tells React that the state update inside is non-urgent
  • React starts rendering the new state but can interrupt it if higher-priority updates come in (like typing)
  • While the transition is pending, isPending is true, letting you show a loading indicator
  • Transitions integrate with Suspense — if a component inside the transition suspends, React shows the old content instead of a fallback
  • In React 19, startTransition can also handle async functions, making it useful for server actions

Parameters & Return Values

ParameterTypeDescription
(none)useTransition takes no parameters
ReturnTypeDescription
isPendingbooleantrue while the transition is in progress
startTransition(callback: () => void) => voidWraps state updates to mark them as non-urgent

Variations

Tab switching with Suspense:

const [tab, setTab] = useState("home");
const [isPending, startTransition] = useTransition();
 
function selectTab(nextTab: string) {
  startTransition(() => {
    setTab(nextTab);
  });
}
 
return (
  <div>
    <nav className={isPending ? "opacity-50" : ""}>
      <button onClick={() => selectTab("home")}>Home</button>
      <button onClick={() => selectTab("posts")}>Posts</button>
    </nav>
    <Suspense fallback={<Spinner />}>
      {tab === "home" ? <Home /> : <Posts />}
    </Suspense>
  </div>
);

React 19 async transitions with server actions:

const [isPending, startTransition] = useTransition();
 
function handleSubmit() {
  startTransition(async () => {
    const result = await saveToServer(formData);
    setData(result); // UI updates after the server responds
  });
}

TypeScript Notes

// isPending is always boolean, startTransition accepts () => void
const [isPending, startTransition] = useTransition();
 
// React 19: startTransition also accepts async functions
startTransition(async () => {
  await serverAction();
});

Gotchas

  • Wrapping synchronous cheap updates — Using startTransition for a simple setCount(count + 1) adds overhead without benefit. Fix: Only use transitions for updates that cause expensive re-renders.

  • Not splitting urgent from non-urgent — Wrapping both the input state and the filter state in startTransition delays the input too. Fix: Keep urgent updates (input value) outside startTransition.

  • isPending stays true too long — If the transition causes a Suspense boundary to suspend, isPending remains true until the suspended content resolves. Fix: This is expected behavior; design your loading states accordingly.

  • startTransition must be synchronous (React 18) — In React 18, the callback must call setState synchronously, not inside a setTimeout or after an await. Fix: In React 18, trigger the state update synchronously. In React 19, async callbacks are supported.

  • Cannot wrap non-React state — Transitions only work with React state updates (useState, useReducer). Updating a ref or external store inside startTransition has no effect. Fix: Ensure the state update is a React state setter.

Alternatives

AlternativeUse WhenDon't Use When
useDeferredValueYou want to defer a specific value without controlling when the update firesYou need explicit control over which updates are non-urgent
DebouncingReducing the frequency of expensive operations (e.g., API calls)You want React to remain responsive during the render itself
Web WorkerThe computation is CPU-heavy and should not block the main thread at allThe work is rendering React components
Virtualization (react-window)Rendering thousands of DOM elementsThe bottleneck is computation, not DOM nodes

Transitions vs. debouncing: Debouncing delays the update entirely. Transitions let React start rendering immediately but interrupt if something more urgent arrives. Transitions keep the old UI visible while rendering the new one.

FAQs

What is the difference between useTransition and useDeferredValue?
  • useTransition wraps the state update itself, giving you explicit control over which updates are non-urgent.
  • useDeferredValue wraps the value consumption, deferring when a child re-renders with the new value.
  • Use useTransition when you control the state update; use useDeferredValue when you receive the value as a prop.
Why should I keep the input state update outside startTransition?
  • Wrapping the input value update in startTransition delays the input from reflecting the user's typing.
  • Only the expensive downstream update (filtering, rendering heavy content) should be in the transition.
  • Splitting urgent (input) from non-urgent (results) keeps the UI responsive.
How does isPending differ from a manually managed loading state?
  • isPending is automatically set to true when the transition starts and false when it completes.
  • You don't need setLoading(true) / setLoading(false) boilerplate.
  • It also integrates with Suspense boundaries -- isPending stays true while suspended content loads.
Gotcha: Can I use startTransition with non-React state (refs, external stores)?
  • No. Transitions only work with React state updates (useState, useReducer setters).
  • Updating a ref or external store inside startTransition has no effect.
  • The state update must call a React state setter to be recognized as a transition.
Can startTransition accept an async function?
  • In React 19, yes. startTransition(async () => { await serverAction(); setState(result); }) is supported.
  • In React 18, the callback must call setState synchronously -- no await inside.
  • Async transitions in React 19 make server actions seamless.
How does useTransition interact with Suspense?
  • When a transition causes a component to suspend, React shows the old content instead of the Suspense fallback.
  • isPending remains true until the suspended content resolves.
  • This prevents the UI from flashing loading states during tab switches or route changes.
Gotcha: Does wrapping a cheap state update in startTransition improve performance?
  • No. For simple updates like setCount(count + 1), startTransition adds overhead without benefit.
  • Transitions are designed for updates that cause expensive re-renders (large lists, heavy components).
  • Only use transitions when you can measure a responsiveness problem.
How do I type useTransition in TypeScript?
const [isPending, startTransition] = useTransition();
// isPending: boolean
// startTransition: (callback: () => void) => void
 
// React 19 also accepts async:
// startTransition: (callback: () => void | Promise<void>) => void
How is useTransition different from debouncing?
  • Debouncing delays the update entirely -- the user waits before anything happens.
  • Transitions let React start rendering immediately but interrupt if something more urgent arrives.
  • Transitions keep the old UI visible while rendering the new one, providing a smoother experience.
Can I nest multiple startTransition calls?
  • Multiple startTransition calls in the same handler are batched into a single transition.
  • React treats all state updates inside startTransition as low-priority together.
  • There is no concept of transition priority levels -- all transitions are equally non-urgent.