Skip to content
Back to Blog
ReactConcurrent RenderinguseTransitionuseDeferredValueReact 19Performance

React Concurrent Rendering Is Not About Speed, It's About Interruption

You'll learn what concurrent rendering actually changed in React (renders became interruptible), why useTransition and useDeferredValue don't make code faster, when to reach for each one, and the caveats that quietly break them: the missing memo, uncancelled requests, and render starvation.

15 min read

TL;DR: Concurrent rendering didn't make React faster. It made renders interruptible. Before, once React started rendering it ran to completion and blocked the main thread, so a keystroke could sit behind a 10,000-row filter. Now you can tag an update as non-urgent with useTransition (when you own the setter) or useDeferredValue (when you only have the value), and React will pause that work to handle typing, clicking, and animation first. The expensive render still costs the same. It just stops holding the UI hostage while it runs.


The bug looks like this. You have a search box over a big list. You type "concurrent" and the input stutters, each keystroke landing a beat late, the cursor lurching. The list is doing its job. The input is the victim.

function ProductSearch({ products }: { products: Product[] }) {
  const [query, setQuery] = useState("");
 
  const filtered = products.filter((p) =>
    p.name.toLowerCase().includes(query.toLowerCase()),
  );
 
  return (
    <>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ProductList items={filtered} /> {/* renders 8,000 rows */}
    </>
  );
}

Every keystroke sets query, which re-renders, which re-filters 8,000 products, which reconciles 8,000 rows. That work runs on the main thread. While it runs, the browser can't paint your keystroke. The input isn't slow. It's blocked, waiting behind a render it has nothing to do with.

The old advice was "make the render cheaper." Memoize the filter, virtualize the list, debounce the input. All valid. But there's a different lever now, and it doesn't touch the cost of the render at all. It changes when that render is allowed to block the screen.


The Word "Concurrent" Is Misleading

Most people hear "concurrent rendering" and picture React rendering two things in parallel, on two threads, at the same time. That's not it. JavaScript is still single-threaded. React still does one thing at a time.

What changed is that a render can now be paused, resumed, and thrown away. Before React 18, rendering was a single synchronous stack. Once React started walking your component tree, it couldn't stop until it hit the bottom. If that took 300ms, the main thread was gone for 300ms, and every click and keystroke in that window queued up behind it.

Concurrent rendering breaks that render into interruptible chunks. React can render partway down the tree, notice a higher-priority update came in (you typed), drop what it was doing, handle the urgent update, and then restart the expensive render from the top. The technical name for the mechanism is lanes: React assigns updates to priority lanes and schedules them so urgent lanes never wait behind non-urgent ones.

You don't touch lanes directly. You get two hooks that say "this update is non-urgent, put it in a lane that can be interrupted":

  • useTransition when you control the state setter.
  • useDeferredValue when you only have the value, not the setter.

That's the entire mental model. Not faster. Not parallel. Interruptible, and prioritized.


The Misconception That Wastes the Most Time

People reach for these hooks expecting a speed boost, wrap a render, see no change in the profiler's "render duration," and conclude the hook is broken.

The hook is not broken. The render duration is supposed to stay the same.

// A transition does NOT make this filter run faster.
// The filter still takes the same milliseconds.
startTransition(() => {
  setResults(hugeArray.filter(expensivePredicate));
});

What a transition changes is whether that filter is allowed to block a keystroke. The filter still costs 200ms of CPU. But now those 200ms are chopped into pieces React can interrupt, so your input paints between the pieces. The metric that improves is INP (Interaction to Next Paint), not render time. You're trading "the render finishes 200ms after the keystroke and the screen was frozen the whole time" for "the keystroke paints in 16ms and the render catches up in the background."

If your component genuinely renders in 3ms, none of this helps you, and adding it makes things marginally worse because React now maintains two versions of the tree. Reach for these hooks when you have measured jank, not on principle.


useTransition: When You Own the Setter

useTransition gives you two things: an isPending flag and a startTransition function. You wrap the state update you want deprioritized.

Here's the search box, fixed. The trick is that query (which drives the input) stays urgent, while the expensive downstream state goes into the transition.

// ✅ input stays urgent, list render is interruptible
function ProductSearch({ products }: { products: Product[] }) {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState(products);
  const [isPending, startTransition] = useTransition();
 
  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const next = e.target.value;
    setQuery(next); // urgent: the input must feel instant
 
    startTransition(() => {
      // non-urgent: React can interrupt this to handle the next keystroke
      setResults(
        products.filter((p) =>
          p.name.toLowerCase().includes(next.toLowerCase()),
        ),
      );
    });
  }
 
  return (
    <>
      <input value={query} onChange={handleChange} />
      <ProductList items={results} style={{ opacity: isPending ? 0.6 : 1 }} />
    </>
  );
}

Notice what's urgent and what isn't. setQuery is outside the transition, so the input updates immediately on every keystroke. setResults is inside, so the 8,000-row reconciliation runs in an interruptible lane. Type fast and React keeps abandoning the half-done list render to paint your next character. When you pause, the list catches up.

The classic mistake is wrapping the wrong setter:

// ❌ the input itself now lags, because you deferred the thing that must be instant
function handleChange(e) {
  startTransition(() => {
    setQuery(e.target.value); // controlled input value is deferred → cursor stutters
  });
}

If a controlled input's value comes from deferred state, the character you typed shows up late. The rule: keep the thing the user is directly manipulating urgent, defer the thing that reacts to it.

The other big use: navigation

Tab and route switches are the textbook transition case. Without one, clicking a tab that renders a heavy panel freezes the whole UI until the panel is ready, including the tab bar itself, so the app feels stuck.

// ❌ clicking a tab freezes the tab bar until the heavy panel renders
function Tabs() {
  const [tab, setTab] = useState("home");
  return (
    <>
      <TabButton onClick={() => setTab("reports")}>Reports</TabButton>
      {tab === "reports" ? <HeavyReports /> : <Home />}
    </>
  );
}
 
// ✅ the tab bar stays responsive; you can even change your mind mid-render
function Tabs() {
  const [tab, setTab] = useState("home");
  const [isPending, startTransition] = useTransition();
 
  function select(next: string) {
    startTransition(() => setTab(next));
  }
 
  return (
    <>
      <TabButton onClick={() => select("reports")} pending={isPending}>
        Reports
      </TabButton>
      {tab === "reports" ? <HeavyReports /> : <Home />}
    </>
  );
}

The payoff people miss: because the render is interruptible, if the user clicks "Reports" then immediately clicks "Settings," React abandons the half-rendered Reports panel and switches to Settings. Without a transition, they'd be stuck watching Reports finish rendering before Settings could even start. This is why every App Router navigation in Next.js is wrapped in a transition under the hood.

React 19: async transitions

React 19 lets you put async work inside a transition, which is the foundation of Actions. isPending stays true until the async work settles.

// ✅ isPending covers the whole async action, not just the sync setState
function SaveButton({ draft }: { draft: Draft }) {
  const [isPending, startTransition] = useTransition();
 
  function save() {
    startTransition(async () => {
      await saveToServer(draft);
      // any setState AFTER the await must also be inside a transition-aware path
    });
  }
 
  return (
    <button onClick={save} disabled={isPending}>
      {isPending ? "Saving..." : "Save"}
    </button>
  );
}

One sharp edge worth naming now, because it burns people: a transition does not order your async calls. Click save three times fast and the responses can land out of order. Transitions deprioritize renders. They don't sequence network requests. If order matters, you still need useActionState's queue or your own request token.


useDeferredValue: When You Don't Own the Setter

Sometimes you can't wrap the setter because you don't own it. The value arrives as a prop, from a URL param, from a library hook. useDeferredValue works at the other end: you take the value and get back a copy that lags during heavy renders.

// ✅ you don't control where `query` came from, but you can defer reacting to it
function SearchResults({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query);
 
  // this expensive child renders against the lagging value
  return <ExpensiveList query={deferredQuery} />;
}

When query changes, React first re-renders with the old deferredQuery (cheap, the list doesn't change), paints, and then re-renders with the new value in the background. If you type again before that background render finishes, React throws it away and restarts. Same interruptibility as a transition, driven from the consumer side.

The caveat that silently makes it do nothing

Here is the single most common useDeferredValue mistake, and it produces zero errors, zero warnings, and zero benefit:

// ❌ ExpensiveList is not memoized, so it re-renders on EVERY render of the parent
function SearchResults({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query);
  return <ExpensiveList query={deferredQuery} />; // re-renders even when deferredQuery didn't change
}
 
function ExpensiveList({ query }) {
  const items = bigData.filter((d) => d.match(query));
  return <List items={items} />;
}

useDeferredValue only helps if the expensive child can skip rendering when the deferred value hasn't changed. If the child isn't wrapped in memo, it re-renders every time the parent does, deferred value or not, and you've paid for the hook while getting nothing.

// ✅ memo lets the child bail out when the deferred value is unchanged
const ExpensiveList = memo(function ExpensiveList({ query }: { query: string }) {
  const items = bigData.filter((d) => d.match(query));
  return <List items={items} />;
});

Now when the parent re-renders with the still-old deferredQuery, ExpensiveList sees the same prop and bails out. Only when the deferred value actually advances does it do the expensive work, and that work runs in the interruptible background lane. The memo is not optional. It's the mechanism.

If the expensive part is a computation rather than a child component, the equivalent move is useMemo keyed on the deferred value:

// ✅ the filter only reruns when the deferred value changes
function SearchResults({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query);
  const items = useMemo(
    () => bigData.filter((d) => d.match(deferredQuery)),
    [deferredQuery],
  );
  return <List items={items} />;
}

useTransition vs useDeferredValue

They ride the same concurrent engine. Both produce interruptible, restartable renders. The difference is purely where you grab the wheel: at the setter, or at the value.

useTransitionuseDeferredValue
What you wrapthe state setter callthe value you receive
You need to own the setterYesNo
Gives you isPendingYes, directlyNo (derive value !== deferred)
Needs memo / useMemo to workNoYes, on the expensive consumer
Best fornavigation, tab switch, form submit, updates you triggerprop-driven or hook-driven values you can't intercept
React 19 async supportYes (startTransition(async () => {}))N/A

A useful default: if you own the setState, use useTransition, it's more explicit and hands you isPending. If the value shows up as a prop you can't intercept, use useDeferredValue. When both would work, useDeferredValue is often less code because there's no separate results state to manage.


Telling the User Something Is Happening

Interruptible renders create a UX question: while the background render catches up, the screen shows stale content. If you say nothing, the user sees old results and assumes their input didn't register. Both hooks give you a way to signal "updating."

With useTransition, use isPending:

// ✅ dim the stale list while the transition is in flight
<ProductList items={results} style={{ opacity: isPending ? 0.6 : 1 }} />

With useDeferredValue, there's no isPending, so you compute staleness by comparing the live value to the deferred one:

// ✅ derive "is this showing stale data?" yourself
function SearchResults({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;
 
  return (
    <div style={{ opacity: isStale ? 0.5 : 1, transition: "opacity 0.2s" }}>
      <ExpensiveList query={deferredQuery} />
    </div>
  );
}

A soft opacity dip reads as "working on it" without the jarring layout shift of swapping in a spinner. Don't rip the stale content out and drop a skeleton in its place. Showing slightly stale results beats showing nothing, which is the whole reason you deferred instead of blocking.


The Caveats That Actually Bite

These hooks look simple and have several sharp edges that don't announce themselves.

They don't cancel network requests. Deferring a render has nothing to do with in-flight fetches. If you fire a request per keystroke, you still fire a request per keystroke.

// ❌ the render is deferred, but this still hits the network on every keystroke
function Search({ query }) {
  const deferredQuery = useDeferredValue(query);
  useEffect(() => {
    fetch(`/api/search?q=${deferredQuery}`).then(/* ... */);
  }, [deferredQuery]);
}

Concurrent rendering solves render jank. Request storms and out-of-order responses are a separate problem that needs debouncing plus an AbortController or a request token. Don't expect a transition to fix a race condition.

Transitions can starve. A transition only commits when React finds a quiet moment. On a page with constant urgent updates (an animation loop, a ticking clock, rapid typing that never pauses), each attempt at the transition gets interrupted and restarted, and it may never commit. isPending sits true indefinitely.

// ⚠️ if urgent updates never stop, this transition may never finish
startTransition(() => setHeavyState(next));

If you need "this will eventually commit no matter what," a raw transition isn't a guarantee. Debounce the urgent signal, or lean on useDeferredValue, which has its own throttling behavior and will settle once input calms down.

Updates inside a transition are still batched and can be interrupted mid-render. If you set a chart's data in a transition and then type into an input, React restarts the chart render after handling the keystroke. That's the feature working, but it means the transition render can run multiple times. Keep the render pure, no side effects, or the restarts will surprise you.

Don't wrap trivial updates. A transition makes React maintain two trees. For a counter or a toggle, that overhead is pure loss.

// ❌ nothing here is expensive; the transition is overhead with no upside
startTransition(() => setCount((c) => c + 1));
 
// ✅ just set it
setCount((c) => c + 1);

When Not to Reach for These at All

Before you add a single concurrency hook, ask whether the render is expensive because it has to be, or because it's doing unnecessary work. A lot of "slow list" problems are really "this component re-renders when it shouldn't" problems, and the fix is memo, a stable callback, or not recreating an object prop every render. Concurrency hooks make an expensive render less disruptive. They don't make an accidentally-expensive render correct.

The order of operations that actually holds up:

  1. Profile. Find the component that's genuinely slow to render (React DevTools, flame graph).
  2. Try to make it not slow: memoize, virtualize the list, cut wasted re-renders.
  3. If it's irreducibly expensive and still janks input, then reach for useTransition or useDeferredValue to stop it from blocking.

Skipping straight to step 3 is how you end up with useDeferredValue sprinkled over a component that renders in 4ms, adding double-buffering overhead to solve a problem you didn't have.


Quick Reference

SituationReach forWatch out for
Search/filter over a big list, you own the stateuseTransition (defer the results setter, keep input urgent)don't defer the input's own value
Tab / route / view switch with a heavy paneluseTransitionshow isPending on the control you clicked
Async form submit / mutationuseTransition (React 19 async) or useActionStatetransitions don't order requests
Expensive child fed by a prop you don't controluseDeferredValuechild MUST be memo'd or it does nothing
Expensive computation from a fast-changing valueuseDeferredValue + useMemokey the useMemo on the deferred value
Trivial state (counter, toggle)neithertransition overhead with no benefit
Request-per-keystroke stormneither (use debounce + AbortController)deferring renders doesn't touch the network

What Changed in How You Should Think About This

The trap is treating concurrent rendering as a performance feature you sprinkle on to make things fast. It isn't. It's a scheduling model. The question it answers is not "how do I make this render cheaper" but "which update is allowed to block the screen right now, and which one can wait."

Once you frame it that way, the two hooks stop feeling like magic and start feeling like what they are: a way to tell React "this update is urgent, that one isn't." useTransition says it at the setter. useDeferredValue says it at the value. React does the rest with lanes you never touch.

The stuttering input from the top of this post was never a speed problem. The filter wasn't too slow. It was too greedy, holding the main thread through a render nobody was waiting on while the one thing the user cared about, their own typing, sat in line behind it. Concurrent rendering doesn't make the filter faster. It just teaches it to yield.