Skip to main content

Concurrent Rendering in React 19, Part 2: startTransition, useTransition and useDeferredValue

Updated 16 min readMarkdown
Part of seriesConcurrent Rendering in React 19
Concurrent Rendering in React 19, Part 2: startTransition, useTransition and useDeferredValue

Introduction: telling React what can wait

startTransition, useTransition, and useDeferredValue are how you tell React: "this update can wait. Keep the UI responsive first."

The rest of this article explains when and how to use each one.

In Part 1, we saw how React learned to pause. It renders in small pieces, keeps a bookmark with Fiber, and handles urgent updates first using lanes.

But there was one catch: React only does this for updates you mark as "can wait". By default, every update is still urgent.

So in this part, we'll take the same frozen search box from Part 1 and fix it, step by step.


This post goes in four steps, and each one builds on the one before:

  1. startTransition: the basic way to mark an update as not urgent.
  2. useTransition: the same idea, plus a pending flag and React 19's async transitions.
  3. useDeferredValue: for when you don't control the state update.
  4. Choosing between them: real use cases, and the common mistakes.

If you only remember one thing by the end, make it this: all three APIs open the same door, the transition lane. The only question is where you put the label: on the update, or on the value.


Section 1: startTransition - marking work as "can wait"

startTransition tells React that the state updates inside it are not urgent. React puts them in the transition lane, renders them in small pieces, and lets any urgent update jump ahead of them.

The React team splits updates into two kinds:

  • Urgent updates: typing, clicking, pressing. Users expect them immediately, the same way a physical button moves when you press it.
  • Transition updates: showing search results, switching a tab, opening a page. Users expect them to take a moment, and they won't notice if they're a little late.

Think of an airport security line with an express lane. Urgent updates use the express lane. Transitions wait in the normal line, and anyone with an express pass can step in front of them at any time.

Syntax

import { startTransition } from 'react';

startTransition(() => {
  setSomeState(newValue); // marked as a transition
});

Two things to know about it

  1. The callback runs right away. startTransition does not delay your function. It runs it immediately and labels the state updates that happen during it.
  2. Only state updates are affected. Any other code inside it (a console.log, a calculation) runs normally. What becomes interruptible is the re-render caused by those updates.

The trick is to split one piece of state into two: one for the input (urgent) and one for the expensive list (can wait).

import { useState, startTransition, memo } from 'react';

function Search({ products }) {
  const [query, setQuery] = useState('');   // drives the input
  const [filter, setFilter] = useState(''); // drives the list

  function handleChange(e) {
    const value = e.target.value;
    setQuery(value);              // urgent: input shows the letter now
    startTransition(() => {
      setFilter(value);           // can wait: list catches up when it can
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      <ProductList products={products} filter={filter} />
    </>
  );
}

const ProductList = memo(function ProductList({ products, filter }) {
  const visible = products.filter(p =>
    p.name.toLowerCase().includes(filter.toLowerCase())
  );
  return (
    <ul>
      {visible.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
});

Example: here's what happens when you type "s":

  • setQuery('s') goes into the urgent lane.
  • setFilter('s') goes into the transition lane.
  • React handles the urgent work first. Search re-renders with query = 's' and filter = ''. ProductList didn't get new props, so memo skips it. The "s" shows up almost instantly.
  • Then React starts the transition render with filter = 's', in small pieces.
  • If you type "h" before it finishes, React throws away the half-built list, shows "sh" in the input, and starts again with filter = 'sh'. The list for "s" is never shown, because nobody needs it anymore.

Don't skip the memo. Without it, the urgent render re-renders ProductList too (children re-render by default, remember Part 1), and you're back to a slow keystroke. If your project uses the React Compiler, it adds this memoization for you.

Where you'll use it

  • Router navigation: routers wrap navigations in transitions so the current page stays usable while the next one renders. The Next.js App Router does this for router.push.
  • Data libraries and stores: a module that applies a big server response to state can wrap it in startTransition without living inside a component.
  • Big UI changes from non-component code: applying a saved dashboard layout, or switching a theme that re-renders hundreds of widgets.

Limitations

Limitation Why
Not for the input's own value The input must update right away, or the cursor and typed letters get out of sync. Split the state like above.
No pending flag It can't tell you if the transition is still running. Need a spinner? Use useTransition.
Doesn't make slow code fast The list still takes 200 ms. Those 200 ms just don't block typing anymore.

Section 2: useTransition - the same idea, with feedback

useTransition is the hook version of startTransition. It does the same job, and adds one thing users need: an isPending flag, so you can show that something is happening.

const [isPending, startTransition] = useTransition();
  • isPending is true from the moment you start the transition until React shows its result.
  • startTransition works like the standalone function, but React tracks it for this component so it can update isPending.

How isPending works under the hood

When you call the hook's startTransition, React actually schedules two updates:

  1. An urgent update that sets isPending to true.
  2. A transition update that sets your state and sets isPending back to false.

The urgent one shows first, so the loading state appears right away. The second one shows only when the whole background render is done.

That's why isPending never lies: it flips back to false in the same commit that shows the new content.

Example: dashboard tabs that never freeze

This is the most common real use. Some tabs are cheap, and one renders a heavy table or a set of charts.

import { useState, useTransition } from 'react';

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

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

  return (
    <>
      <TabBar active={tab} onSelect={selectTab} pending={isPending} />
      <section style={{ opacity: isPending ? 0.6 : 1 }}>
        {tab === 'overview' && <Overview />}
        {tab === 'orders' && <OrdersTable />}
        {tab === 'analytics' && <AnalyticsCharts />}
      </section>
    </>
  );
}

Example: you click "Analytics", and its charts are heavy.

  • Without a transition: the page freezes until all the charts render.
  • With a transition: the click responds right away (the content dims), the old tab stays visible and usable, and if you change your mind and click "Orders" halfway through, React drops the chart work and switches.

Transitions and Suspense

Normally, when a component suspends (it's waiting for code from lazy() or data from use()), React shows the nearest <Suspense> fallback. That's fine on first load. But if the user is already looking at a tab, swapping it for a big spinner feels sudden and annoying.

When the update that caused the suspension is a transition, React keeps showing the old UI and waits for the new one to be ready. You use isPending to show a small indicator instead.

This only protects content that's already on screen. A brand-new <Suspense> boundary showing for the first time will still show its fallback, and that's what you want.


Section 3: useDeferredValue - when you don't own the update

useDeferredValue gives you a copy of a value that's allowed to lag behind. Urgent renders see the old copy, and a background render catches it up.

You use it when you can't wrap the state update in a transition, usually because the value comes in as a prop, or from code you don't control.

The difference in one line:

  • useTransition wraps the update: "this setState can wait".
  • useDeferredValue wraps the value: "the parts of the UI that read this value can wait".

Think of a live TV broadcast with delayed subtitles. The picture (your input) is live. The subtitles (the heavy list) can lag a little and catch up, without ever stopping the broadcast.

Syntax

const deferredValue = useDeferredValue(value);

// React 19: optional initial value for the very first render
const deferredValue = useDeferredValue(value, initialValue);

Fixing the search box, version two

Same problem as Section 1, but this time we keep one piece of state.

import { useState, useDeferredValue, memo } from 'react';

function Search({ products }) {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <div style={{ opacity: isStale ? 0.6 : 1 }}>
        <ProductList products={products} filter={deferredQuery} />
      </div>
    </>
  );
}

const ProductList = memo(function ProductList({ products, filter }) {
  // same expensive filtering as before
});

No split state, no handler logic. isStale gives us the same "something is coming" signal that isPending gave us before.

What happens on each keystroke

useDeferredValue causes two renders for every change:

  1. Urgent render: query is 's', but useDeferredValue still returns the old value ''. ProductList gets the same prop as before, so memo skips it. The input updates right away.
  2. Background render: React schedules a second render in the transition lane, where useDeferredValue returns 's'. Now ProductList re-renders, in small pieces.

If you type again during step 2, React throws that background render away and starts over with the latest value.

Again: memo is not optional. Without it, render 1 re-renders ProductList anyway, and the hook does nothing. (The React Compiler handles this for you.)

It is not a debounce

People often call useDeferredValue "built-in debouncing". It isn't, and the difference matters:

Debounce (300 ms) useDeferredValue
Delay Fixed, even on fast devices None. Starts right away and adapts to the device
When the update runs Blocks the main thread Interruptible, gives the browser time every few ms
Stale work Runs to the end Thrown away when a newer value arrives
Fewer network requests Yes No

That last row is the key. useDeferredValue is about rendering, not fetching. If every keystroke sends an API call, you still want a debounce (or request cancellation) for the network. The two work well together.

React 19: initialValue

By default, on the very first render, useDeferredValue returns the real value, so the first paint is just as heavy as without the hook. React 19 adds a second argument:

const deferredResults = useDeferredValue(results, []);

On mount, React first renders with [] (cheap, paints fast), then schedules a background render with the real results.

Example: a dashboard with a heavy chart.

  • Without initialValue: the whole page waits for the chart, and the user stares at a blank screen.
  • With initialValue: the page appears right away, and the chart fills in a moment later.

Where you'll use it

  • Search and autocomplete where the query comes in as a prop or from the URL.
  • Charts that follow a slider or a date-range picker: the control moves smoothly, the chart catches up.
  • Third-party inputs (a date picker, a rich text editor) that own their state, so you can't wrap their updates.
  • Live previews: a Markdown editor with a preview, or a JSON editor with a tree view.

Section 4: Choosing the right tool

All three APIs open the same door, the transition lane. So the choice comes down to two questions:

  1. Do you control the state update?
  2. Do you need to show a pending state?
startTransition useTransition useDeferredValue
What you wrap The state update The state update The value
Where you can use it Anywhere, even outside components Inside components only Inside components only
Pending indicator None isPending value !== deferredValue
Best for Routers, libraries, non-component code Tabs, navigation, filters, save buttons Props, third-party inputs, URL values

Before reaching for any of them, ask: can this work be avoided? Transitions keep the UI responsive while slow work happens. They don't remove the slow work. A 10,000-row list should usually be virtualized first. First, try to make the work smaller. If it’s still slow after that, use a transition.

Putting it together: an analytics dashboard

Here's how all two fit into one real screen: tabs, a date-range slider driving a chart.

import { useState, useTransition, useDeferredValue, memo } from 'react';

function AnalyticsPage({ initialRange }) {
  // 1. Tabs: we own the update and want feedback -> useTransition
  const [tab, setTab] = useState('revenue');
  const [isTabPending, startTabTransition] = useTransition();

  // 2. Range slider: input must be instant, chart can lag -> useDeferredValue
  const [range, setRange] = useState(initialRange);
  const deferredRange = useDeferredValue(range);
  const isChartStale = range !== deferredRange;

  return (
    <>
      <Tabs
        value={tab}
        onChange={next => startTabTransition(() => setTab(next))}
        pending={isTabPending}
      />

      <RangeSlider value={range} onChange={setRange} />

      <div style={{ opacity: isChartStale || isTabPending ? 0.6 : 1 }}>
        <MetricChart metric={tab} range={deferredRange} />
      </div>
    </>
  );
}

const MetricChart = memo(function MetricChart({ metric, range }) {
  // expensive: aggregates thousands of data points and draws an SVG chart
});

Why each choice:

  • Tabs: we call setTab ourselves and want to dim the content, so useTransition.
  • Range slider: setRange must be urgent so the slider follows the mouse. The chart reads the deferred copy, so useDeferredValue.
  • memo on the chart: without it, neither the transition nor the deferred value can skip the chart during urgent renders.

Common mistakes

Almost all of these come from forgetting one of two rules from Part 1: urgent renders still re-render children, and background renders can be thrown away.

1. Putting the input's own value in a transition

// ❌ Wrong: the input lags or drops letters
function handleChange(e) {
  startTransition(() => setQuery(e.target.value));
}

// ✅ Right: the input is urgent, the expensive part can wait
function handleChange(e) {
  setQuery(e.target.value);
  startTransition(() => setFilter(e.target.value));
}

Mark the result of typing as a transition, never the typing itself.

2. Forgetting memo on the expensive child

Without memo, the urgent render re-renders the slow component with its old props. You pay the full cost on every keystroke, and the transition just adds a second render on top.

If you add useDeferredValue and see no improvement, check this first.

3. Setting state after await without wrapping it

// ❌ Not a transition: setReport runs as a normal, blocking update
startTransition(async () => {
  const data = await fetchReport(range);
  setReport(data);
});

// ✅ Right
startTransition(async () => {
  const data = await fetchReport(range);
  startTransition(() => setReport(data));
});

4. Using a timer inside the transition

// ❌ Wrong: when the timeout fires, the transition is already over
startTransition(() => {
  setTimeout(() => setPage(2), 100);
});

// ✅ Right: wrap the update wherever it happens
setTimeout(() => {
  startTransition(() => setPage(2));
}, 100);

The callback runs right away, so only updates made during that call get the label.

5. Side effects in the render body

function ResultsList({ results }) {
  analytics.track('results_viewed', { count: results.length }); // ❌
  return <List items={results} />;
}

With concurrent rendering, a render can run several times, or be thrown away before it reaches the screen. This code would log views the user never saw. Put it in an effect, which only runs after a real commit.

6. Passing a new object to useDeferredValue

// ❌ Wrong: a new object every render, so it never settles
const deferred = useDeferredValue({ query, sort });

// ✅ Right: defer simple values
const deferredQuery = useDeferredValue(query);
const deferredSort = useDeferredValue(sort);

React compares the values with Object.is. A new object is always "different", so React does an extra background render every time.

7. Expecting useDeferredValue to reduce API calls

It defers rendering, not fetching. If every keystroke sends a request, add a debounce, cancel old requests with AbortController, or use a data library that does it for you.

8. Expecting transitions to fix slow code

A transition makes slow work interruptible, not faster. If a render takes two seconds, users still wait two seconds for the result. Profile with React DevTools first, then reduce the work (virtualize, memoize, etc.).


Wrapping up the series

Here's the whole series in short points:

  1. Rendering works out what the UI should look like. React can repeat it or throw it away. Commit is the only step that changes the DOM.
  2. Old React couldn't stop. Once a big update started, the page froze until it was done.
  3. Fiber, time slicing, and lanes let React work in small pieces and handle urgent updates first.
  4. startTransition marks state updates as "can wait". It works anywhere, even outside components.
  5. useTransition does the same and gives you isPending.
  6. useDeferredValue defers a value instead of an update, for when you don't control the setState.
  7. None of them make code faster. Measure first, reduce the work, then use transitions to keep the UI responsive.

Try it yourself: take the frozen search box, add an artificial slowdown to each row, and try it three ways: plain, with startTransition, and with useDeferredValue. Then turn on CPU throttling in Chrome DevTools. The difference you feel in the input is the whole point of this series. 👀

Further reading

About the author

  • A Senior frontend engineer (5+ years) building production apps with React, Next.js, Vue and TypeScript. Clear under-the-hood explanations, real-world examples, and senior interview prep.

No comments yet. Be the first to comment.