C.W.K.
Stream
Lesson 01 of 06 · published

useTransition — Non-Urgent State Updates

~12 min · usetransition, concurrent, priority

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
Some state updates need to feel instant (a key press). Others can take a moment (rendering 10,000 search results). useTransition lets you tell React which is which, so the urgent ones never wait for the non-urgent ones.

The shape

const [isPending, startTransition] = useTransition(). Wrap any state update inside startTransition(() => setX(...)) and React tags it low-priority. The hook tells you if any in-flight transition is still running.

The classic use case — search

Typing in a search input should always feel instant. But updating the results list with a new query might re-render a heavy component tree. Without transitions, typing 'react' would block for tens of milliseconds per keystroke. With a transition wrapping the results update, the input updates immediately (urgent) and the results catch up in the background (non-urgent).

The Suspense interaction

Transitions also smooth Suspense fallbacks. When a state change causes a child to suspend, normally the Suspense fallback flashes in. Inside a transition, React keeps the previous content visible until the new one is ready — no fallback flash. isPending tells you when to show a subtle pending indicator (e.g. a loading bar at the top) without blanking the page.

What goes inside startTransition

Only the state update. Don't wrap fetches, side effects, or anything async. The pattern is: do the work, then mark the state change as a transition:

const data = await fetchResults(query);
startTransition(() => setResults(data));
Priority is communication. React can't infer which updates are urgent — only you know. useTransition is how you tell it. The hooks that exist before transitions (useState, useEffect) treated every update as equally urgent. That assumption is what made React feel janky on big trees.

Code

Search with a transition — input stays responsive·tsx
import { useState, useTransition } from "react";

function SearchPage() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<string[]>([]);
  const [isPending, startTransition] = useTransition();

  function onInput(value: string) {
    setQuery(value); // urgent — input updates instantly
    startTransition(() => {
      // non-urgent — rendering 10k results doesn't block typing
      setResults(filterMassiveDataset(value));
    });
  }

  return (
    <div>
      <input value={query} onChange={(e) => onInput(e.target.value)} />
      {isPending && <p className="text-muted text-sm">Updating…</p>}
      <ul style={{ opacity: isPending ? 0.6 : 1 }}>
        {results.map((r) => <li key={r}>{r}</li>)}
      </ul>
    </div>
  );
}

function filterMassiveDataset(_q: string): string[] {
  // Imagine: 10,000 rows, filtering and sorting.
  return [];
}
Transition smooths Suspense — no fallback flash·tsx
import { Suspense, useState, useTransition } from "react";

function TabbedView() {
  const [tab, setTab] = useState<"a" | "b">("a");
  const [isPending, startTransition] = useTransition();

  function switchTab(next: "a" | "b") {
    // Without startTransition: switching tabs would flash the Suspense fallback.
    // With it: previous tab stays visible until new tab's data resolves.
    startTransition(() => setTab(next));
  }

  return (
    <div>
      <nav className="flex gap-2" style={{ opacity: isPending ? 0.6 : 1 }}>
        <button onClick={() => switchTab("a")}>A</button>
        <button onClick={() => switchTab("b")}>B</button>
      </nav>
      <Suspense fallback={<p>Loading…</p>}>
        {tab === "a" ? <TabA /> : <TabB />}
      </Suspense>
    </div>
  );
}

function TabA() { return <p>A</p>; }
function TabB() { return <p>B</p>; }

External links

Exercise

Build a list filter where typing in an input filters a list of 5000 items. Without useTransition, the input lags as you type. Add useTransition around the filter update and observe: typing stays instant, the filtered results catch up smoothly. Use the isPending flag to dim the list during the transition so the user sees the in-flight state.
Hint
Generate the 5000 items with Array.from({ length: 5000 }, (_, i) => item-${i}). The filter is items.filter(i => i.includes(query)). Without a transition, the per-keystroke filter blocks input.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.