# 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](https://refaat1297.hashnode.dev/concurrent-rendering-in-react-19-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

```typescript
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.

### Fixing the frozen search box

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

```typescript
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.

![](https://cdn.hashnode.com/uploads/covers/63dd1cd72f21c58845cbb7b8/e56e7456-5764-4455-bc6d-d9f9881e4d2f.png align="center")

### 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.

```typescript
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.

```typescript
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.

![](https://cdn.hashnode.com/uploads/covers/63dd1cd72f21c58845cbb7b8/a5df03a8-6058-4501-98aa-09e6f865f4a7.png align="center")

> 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

```typescript
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.

```typescript
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.

![](https://cdn.hashnode.com/uploads/covers/63dd1cd72f21c58845cbb7b8/80336c25-df0a-4ce7-984d-d4a999277606.png align="center")

> 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:

```typescript
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 |

![](https://cdn.hashnode.com/uploads/covers/63dd1cd72f21c58845cbb7b8/2def96f5-4d0f-4bda-bab9-e05ee457bdd3.png align="center")

> 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.

```typescript
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

```typescript
// ❌ 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

```typescript
// ❌ 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

```typescript
// ❌ 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

```typescript
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`

```typescript
// ❌ 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

- [useTransition](https://react.dev/reference/react/useTransition), [startTransition](https://react.dev/reference/react/startTransition), and [useDeferredValue](https://react.dev/reference/react/useDeferredValue): the official docs, with interactive examples.
