background-shape
React Concurrent Rendering, useTransition and useDeferredValue Explained
April 4, 2022 · 6 min read · by Muhammad Amal programming

TL;DR — Concurrent rendering lets React pause an expensive render to handle a higher-priority update. Mark expensive state updates as transitions with useTransition (when you control the setter) or useDeferredValue (when you don’t). Profile first; reach for these only where input lag is actually visible.

After the React 18 overview, the first feature worth diving into is concurrent rendering — the engine behind useTransition and useDeferredValue. It’s also the most-misunderstood React 18 feature. People sprinkle startTransition everywhere assuming it makes things “faster.” It doesn’t. It makes things more responsive for a specific failure mode.

This post is the mental model + the API + the cases where each one wins.

What problem concurrent rendering solves

Pre-React 18, every state update was synchronous from React’s perspective. Once a render started, nothing could interrupt it. If a state change triggered a render that took 200 ms, the UI was unresponsive for 200 ms. Type a key during that window? The keystroke waited.

React 18’s concurrent rendering changes that. Some state updates can be marked as “transitions” — non-urgent. While the transition’s render is in progress, an urgent update (like keystroke input) can interrupt it, render the urgent change first, then resume.

Concretely: typing in a search box with 10K-row results. Before, every keystroke triggered a synchronous list re-render — input felt laggy. After, the input update is urgent, the list re-render is a transition that gets interrupted by each new keystroke, only fully rendering after the user pauses.

useTransition: when you call the setter

useTransition is the basic API. It returns [isPending, startTransition]:

import { useState, useTransition } from 'react';

function SearchableList({ items }) {
  const [query, setQuery] = useState('');
  const [filter, setFilter] = useState('');
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    const value = e.target.value;
    setQuery(value);             // urgent — input updates immediately
    startTransition(() => {
      setFilter(value);           // non-urgent — list filter render can be interrupted
    });
  }

  const filtered = items.filter(item =>
    item.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <span>filtering</span>}
      <ul>{filtered.map(item => <li key={item}>{item}</li>)}</ul>
    </>
  );
}

Two state pieces: query (urgent, what the user sees in the input) and filter (non-urgent, drives the heavy list render). The input stays responsive; the list catches up when it can.

isPending flips to true during the transition. Useful for showing a subtle loading indicator without flickering the whole list.

useDeferredValue: when you don’t control the setter

useDeferredValue is the deferred-output cousin. Same idea, different shape. Use it when the value comes from a prop or context and you can’t wrap the setter:

import { useDeferredValue } from 'react';

function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  const results = expensiveFilter(deferredQuery);

  return (
    <div style={{ opacity: isStale ? 0.5 : 1 }}>
      <ResultList results={results} />
    </div>
  );
}

React keeps the prior value of query around. The expensive filter uses the deferred (stale) value while a new render is in progress. When the new render finishes, deferredQuery updates and the UI catches up.

isStale comparison is a common pattern — visual cue that the list isn’t current.

When to use which

Use useTransition when you’re the one calling the setter and you can mark the call as a transition. Most “I’m filtering a list” or “I’m switching tabs” cases.

Use useDeferredValue when the value comes from outside your component (prop, context) and you can’t reach into the setter. Or when you want deferred behaviour even for synchronous updates that originate from external state.

For a deep <SearchableList query={query} items={items} /> where query is a prop, you’d use useDeferredValue inside the component. If you owned both the input and the list, you’d use useTransition at the call site.

What concurrent rendering does NOT do

A few common misconceptions worth killing:

It does not make renders faster. The same JS still runs. It just runs interruptibly. The total CPU cost is unchanged or slightly higher.

It does not make data fetching faster. Suspense + transitions can change when you show loading states, but the network is the network.

It does not magically fix slow components. A 300 ms render is still 300 ms total. Concurrent rendering lets a higher-priority 5 ms update jump the queue. The 300 ms render still has to happen.

It does not work without createRoot. Without the new root, transitions silently act like normal updates.

A non-trivial example

A common real-world case: tab switching where each tab is heavy.

function App() {
  const [tab, setTab] = useState('home');
  const [isPending, startTransition] = useTransition();

  function selectTab(next) {
    startTransition(() => setTab(next));
  }

  return (
    <>
      <TabBar
        current={tab}
        onSelect={selectTab}
        isPending={isPending}
      />
      {tab === 'home' && <HomeTab />}
      {tab === 'reports' && <ReportsTab />}
      {tab === 'settings' && <SettingsTab />}
    </>
  );
}

function TabBar({ current, onSelect, isPending }) {
  return (
    <div>
      {['home', 'reports', 'settings'].map(t => (
        <button
          key={t}
          onClick={() => onSelect(t)}
          aria-current={current === t}
          style={{ opacity: isPending && current !== t ? 0.6 : 1 }}
        >
          {t}
        </button>
      ))}
    </div>
  );
}

Click “reports” while it’s heavy. The button highlight responds immediately (because isPending flips synchronously). The heavy <ReportsTab> renders without blocking. If the user clicks “settings” before reports finishes, React abandons the reports render and starts settings.

Pre-React 18, the click was unresponsive until reports finished. Now it’s fluid.

startTransition outside hooks

There’s also a standalone startTransition:

import { startTransition } from 'react';

function handleClick() {
  startTransition(() => {
    setCount(c => c + 1);
  });
}

Same as useTransition’s second value, but without the isPending flag. Use when you don’t need the pending state. Useful in event handlers that don’t have hook context.

Common Pitfalls

Wrapping urgent updates in transitions. setQuery in the search example must NOT be in the transition — otherwise the input lags. Only wrap the expensive downstream updates.

Reaching for transitions to “make things fast.” They’re for responsiveness, not speed. If your render is 200 ms, fix the render (memoization, virtualization, less work). Transitions are layer on top.

Conditional setters that conditionally use transitions. Inconsistent transition use leads to confusing UX. Pick a rule per component.

Deferred values that aren’t actually used in expensive renders. useDeferredValue only helps if you use the deferred value in the expensive child. Passing the non-deferred prop down defeats it.

Spamming startTransition. Each transition is a unit. Wrapping fifteen setters in fifteen separate transitions doesn’t help. Group related setters into one transition call.

Forgetting to switch to createRoot. Without it, all transitions are silently treated as normal updates. No error, no warning. Just no benefit.

Wrapping Up

Concurrent rendering + useTransition + useDeferredValue are the right tools for a specific problem: input responsiveness during expensive renders. Use them surgically. Wednesday: Suspense for data fetching — the other half of what concurrent React enables.