useTransition: Keeping the UI Responsive

useTransition splits one state update into two tracks — the urgent user input and the expensive downstream work — so the UI stays responsive while React processes the heavy part.

March 22, 20264 min read2 / 5

The best way to understand useTransition is to start with a problem that makes it obvious.

Imagine a search over a thousand Pokémon — fuzzy matching across name, description, types, and species. On every keystroke, the filter runs. On a throttled CPU (or a mid-range phone on the subway), you start seeing noticeable lag. Not just slow results — the input field itself lags. You press a key and can see your finger move before the character appears on screen.

Opening Chrome DevTools confirms it: CPU pegged at 100% during keystrokes. Frame events taking over a second, when the target is 16.6ms.

The problem isn't just that the filter is slow. The problem is that React treats showing the character the user just typed and re-running the expensive filter as the same priority. Both updates are scheduled together. The fast thing waits for the slow thing.

useTransition is how you tell React they're not the same priority.

The Two-State Pattern

The insight is that there are actually two pieces of state here:

  1. What the user sees in the input field — this must update instantly
  2. What the filter runs against — this can wait

Without useTransition, you'd have one query state doing both jobs. The fix is to separate them:

TSX
const [inputQuery, setInputQuery] = useState(''); // what the input shows const [searchQuery, setSearchQuery] = useState(''); // what the filter runs against

And then update them on different tracks.

Using useTransition

useTransition returns a tuple:

TSX
const [isPending, startTransition] = useTransition();
  • isPending — a boolean, true while a transition is in progress
  • startTransition — a function that marks any state updates inside it as non-urgent

With the two-state pattern in place:

TSX
function PokemonSearch() { const [inputQuery, setInputQuery] = useState(''); const [searchQuery, setSearchQuery] = useState(''); const [isPending, startTransition] = useTransition(); const filteredPokemon = useMemo( () => fuzzySearch(allPokemon, searchQuery), [searchQuery] ); function handleChange(e) { const value = e.target.value; // ✅ Urgent — updates immediately, user sees their keypress setInputQuery(value); // ✅ Non-urgent — React schedules this when it has capacity startTransition(() => { setSearchQuery(value); }); } return ( <div> <input value={inputQuery} onChange={handleChange} /> {/* Visual feedback while transition is pending */} <ul style={{ opacity: isPending ? 0.5 : 1 }}> {filteredPokemon.map(p => <PokemonCard key={p.id} pokemon={p} />)} </ul> </div> ); }

Reading the handleChange function: when the input changes, immediately update inputQuery (the user sees their keystroke at once), and when React has capacity and the urgent queue is clear, update searchQuery (which triggers the expensive filter via useMemo).

The input stays snappy. The filter result catches up behind it.

What isPending Is For

isPending is true from the moment startTransition is called until the transition resolves. You can use it to show the user that something is happening:

TSX
// Drop opacity while filtering <ul style={{ opacity: isPending ? 0.5 : 1 }}> // Or show a loading indicator {isPending && <Spinner />} // Or animate with Tailwind <ul className={isPending ? 'animate-pulse' : ''}>

The key thing: these visual cues don't block interaction. The user can keep typing while the list is faded. Each new keystroke kicks off a new transition (and cancels the previous one that hasn't resolved yet).

Interruptible by Design

This is the real payoff from React Fiber's lane model. If the user types quickly, the search transition from keystroke 3 can be interrupted by keystroke 4. React doesn't have to finish filtering for "reac" before it can start filtering for "react". The previous transition is abandoned, and a new one starts with the latest value.

Without transitions, every keystroke queues a render that must complete before the next one can start. With transitions, intermediate renders can be discarded.

Compared to Debouncing

The old approach to this problem was debouncing — wait until the user stops typing for 300ms, then trigger the search.

Debouncing is blunt. It adds artificial delay even for users who type slowly. And if the computation finishes in 50ms, you've still waited 300ms unnecessarily.

useTransition is different: it doesn't add delay, it changes priority. The urgent work (input update) runs immediately. The expensive work runs as soon as React has capacity, which might be within milliseconds for a fast device or after a few frames on a slow one. The scheduling is adaptive.

The Code is Small

Given how much is happening under the hood — priority queues, interruptible rendering, cooperative scheduling — the code to set this up is surprisingly minimal:

  1. Split one state into two (urgent and deferred)
  2. Wrap the deferred update in startTransition
  3. Use isPending for visual feedback

That's it. The React Fiber machinery does the rest.

Practice what you just read.

Keep Input Snappy with useTransition
1 exercise

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.