Concurrent Rendering: Transitions, Scheduling, and Responsiveness

Understand React's concurrent rendering mental model — interruptible, priority-based rendering — and how startTransition, useTransition, and useDeferredValue let you mark some updates as lower priority so urgent ones (like typing) stay responsive.

Before concurrent rendering, once React started rendering an update, it ran to completion on the main thread — if that update was expensive (re-rendering a huge list, recalculating a large derived value), the browser couldn't do anything else meanwhile, including responding to the next keystroke or click. Concurrent rendering changes this: React can now start rendering an update, pause partway through if something more urgent comes in, handle that urgent update first, and resume or restart the paused one afterward. Transitions are the API surface that lets you tell React which updates are allowed to be interrupted like this.

Urgent vs non-urgent updates

React's concurrent model divides state updates into two conceptual categories: urgent updates, which need to feel instantaneous because they reflect direct user interaction (typing into a text field, clicking a button, dragging a slider — the UI showing your keystroke or the button's pressed state), and non-urgent (transition) updates, which can visibly lag behind without harming the experience because they represent a resulting change rather than the interaction itself (re-filtering a large list based on what was typed, swapping to a new tab's content).

By default, every state update in React is treated as urgent — synchronous and blocking in the sense that React tries to reflect it in the UI as soon as possible, no exceptions. The problem this creates is that a single state update triggering an expensive re-render (say, re-filtering 10,000 rows on every keystroke) makes that update block everything else, including the very keystroke rendering that triggered it — so typing feels laggy even though the input's own state update should be instant.

The fix isn't to make the expensive work faster (though that helps too) — it's to tell React explicitly: 'this particular update can be treated as lower priority; feel free to interrupt it if something more urgent shows up, and it's fine if the UI lags slightly behind for this one.' That's exactly what a transition marks.

startTransition and useTransition

startTransition(callback), imported from React, wraps a state update to mark it as a transition — any setState calls made synchronously inside the callback are treated as interruptible, lower-priority work rather than urgent, blocking work. useTransition() is the hook form, returning [isPending, startTransition], where isPending is a boolean React manages for you, true while the transition is in progress — useful for showing a subtle loading indicator without blocking the interaction that triggered it.

The typical pattern splits one interaction into two updates: an urgent one for whatever needs to feel instant (usually the input's own displayed value, kept as a normal setState so the text field itself never lags) and a transition-wrapped one for the expensive derived work (like re-filtering a list based on that input). Because the transition update is interruptible, if the user types another character before the filtering finishes, React abandons the in-progress (now-stale) transition render and starts a fresh one for the latest input — rather than finishing stale work that's already outdated.

It's important that startTransition wraps the setState call itself, synchronously, not an async operation — startTransition(() => setQuery(value)) is correct, but startTransition(async () => { await fetch(...); setQuery(value); }) does not mark the eventual setState as a transition, since it happens outside the synchronous callback React is tracking.

useDeferredValue: transitions without owning the state update

useDeferredValue(value) offers a different angle on the same idea: instead of wrapping the state update that produces a value, you wrap the value itself where you consume it, and React gives you back a 'deferred' version that lags behind the real one during urgent updates, catching up once things settle. This is especially useful when the value comes from a prop or from state you don't control the setter for — you can't wrap someone else's setState in startTransition, but you can defer how you use the resulting value locally.

A common pattern: const deferredQuery = useDeferredValue(query); where query updates urgently (so the input feels instant), and an expensive list computed from deferredQuery (rather than query directly) lags behind slightly during rapid typing, then catches up once typing pauses — functionally similar to wrapping the list-filtering update in a transition, but expressed at the point of use instead of the point of update.

useDeferredValue and useTransition solve overlapping problems from different ends: useTransition marks 'this update is low priority' at the moment you set state; useDeferredValue marks 'let me lag behind on reading this value' at the moment you consume it. Which one fits depends on whether you own the state update in question.

Interruptible rendering, tearing, and consistency

The reason React can safely interrupt an in-progress render is that concurrent rendering is designed to never let a component see inconsistent state mid-update — React builds the new tree's output without committing any of it to the actual DOM until the whole (possibly resumed, possibly restarted) render is complete, so an interrupted render simply gets thrown away and restarted, never leaving the visible UI in a half-updated, torn state where some components reflect old props/state and others reflect new ones.

'Tearing' is the term for exactly that inconsistency — different parts of the UI reflecting different points in time for what should be the same underlying state — and it's a hazard concurrent rendering explicitly guards against by committing updates atomically, all at once, rather than piecemeal as each component finishes re-rendering.

This consistency guarantee is also why transitions are safe to use liberally for appropriate updates: marking something as interruptible doesn't risk showing the user a broken intermediate state, it only risks showing them a stale-but-fully-consistent state for slightly longer, which is a much easier trade-off to reason about.

useTransition for a filtered list

setQuery runs outside startTransition, so the input's own value never lags. setFiltered runs inside startTransition, so a fast typist interrupts a still-computing filter rather than waiting for it.

import { useState, useTransition } from "react";

function SearchableList({ items }: { items: string[] }) {
  const [query, setQuery] = useState("");
  const [filtered, setFiltered] = useState(items);
  const [isPending, startTransition] = useTransition();

  function handleChange(value: string) {
    setQuery(value); // urgent: input stays responsive
    startTransition(() => {
      setFiltered(items.filter((i) => i.includes(value))); // non-urgent, interruptible
    });
  }

  return (
    <div>
      <input value={query} onChange={(e) => handleChange(e.target.value)} />
      {isPending && <span>Updating…</span>}
      <ul>{filtered.map((i) => <li key={i}>{i}</li>)}</ul>
    </div>
  );
}

Code Examples

query updates urgently so typing feels instant; deferredQuery (used for the expensive filter) lags a step behind during rapid typing and catches up once typing slows, without ever blocking the input. Expected result: The input's displayed text always matches every keystroke immediately; the filtered list below it may visibly update a beat later during fast typing, then settles once typing pauses.

const deferredQuery = useDeferredValue(query);

Clicking rapidly between tabs stays responsive because each tab switch is interruptible — clicking Comments while Posts is still rendering abandons that stale render and starts fresh, rather than forcing the user to wait for a render they no longer want. isPending dims the content slightly during the transition, giving visual feedback without blocking further clicks.

import { useState, useDeferredValue, useMemo } from "react";

function SearchableList({ items }: { items: string[] }) {
  const [query, setQuery] = useState("");
  const deferredQuery = useDeferredValue(query);

  const filtered = useMemo(
    () => items.filter((i) => i.includes(deferredQuery)),
    [items, deferredQuery]
  );

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ul>{filtered.map((i) => <li key={i}>{i}</li>)}</ul>
    </div>
  );
}
import { useState, useTransition } from "react";

function Tabs() {
  const [tab, setTab] = useState<"posts" | "comments">("posts");
  const [isPending, startTransition] = useTransition();

  function selectTab(next: "posts" | "comments") {
    startTransition(() => {
      setTab(next); // expensive re-render deferred, interruptible
    });
  }

  return (
    <div>
      <button onClick={() => selectTab("posts")} disabled={tab === "posts"}>
        Posts
      </button>
      <button onClick={() => selectTab("comments")} disabled={tab === "comments"}>
        Comments
      </button>
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        {tab === "posts" ? <PostsList /> : <CommentsList />}
      </div>
    </div>
  );
}

Urgent updates vs transitions

Urgent updates (typing, clicking) always render immediately; transition-marked updates are lower priority and can be interrupted or delayed so urgent work is never blocked behind them.

User Input
   ↓
Urgent Update → Immediate UI

Expensive Update
   ↓
Transition
   ↓
Can be scheduled without blocking urgent work

Important Rules

Wrap only the expensive, non-essential-to-feel-instant part of an update in startTransition — keep the part that needs to feel immediate (like an input's own value) as a normal, urgent setState.

startTransition must wrap a synchronous callback that calls setState directly; wrapping an async function around an eventual setState does not mark that update as a transition.

Use useDeferredValue when you're consuming a value you don't control the update for (e.g. a prop); use useTransition/startTransition when you do control the state update itself.

Concurrent rendering never shows torn (inconsistent) UI — an interrupted render is simply discarded and restarted, so marking updates as transitions never risks a broken intermediate state.

Where transitions matter in real apps

Search-as-you-type interfaces over large datasets, tab or view switches that trigger heavy re-renders, and any UI where typing or clicking should never visibly stutter because of a downstream expensive computation are the classic use cases — e-commerce filter panels, analytics dashboards switching between large chart views, and autocomplete/search components commonly wrap their filtering or view-swap logic in startTransition or read from a useDeferredValue so the interaction itself (typing, clicking) stays crisp regardless of how expensive the resulting render is.

function handleChange(value: string) { startTransition(async () => { await fetchResults(value); setResults(value); // NOT recognized as a transition — happens outside the sync callback }); } function handleChange(value: string) { fetchResults(value).then((data) => { startTransition(() => { setResults(data); // synchronous setState inside startTransition IS a transition }); }); } startTransition only marks setState calls made synchronously within its callback as low-priority; an async callback's setState runs after the callback has already returned, outside the window React is tracking, so it's treated as an ordinary urgent update instead.

function handleChange(value: string) { startTransition(() => { setQuery(value); // input's own displayed value, now deferred too setFiltered(items.filter((i) => i.includes(value))); }); } function handleChange(value: string) { setQuery(value); // keep urgent so the input never lags startTransition(() => { setFiltered(items.filter((i) => i.includes(value))); }); }

Wrapping the input's own value update in startTransition makes the typed text itself lag behind keystrokes, which defeats the point — only the expensive derived update (the filtering) should be deferred, not the direct reflection of user input.

[multiple-choice] What is the key difference between an urgent update and a transition update in React's concurrent model? A. Transitions run on a separate thread from urgent updates B. Urgent updates render immediately and cannot be interrupted; transition updates are lower priority and can be interrupted or deferred C. There is no functional difference, only a naming convention D. Transitions always run before urgent updates

B. Urgent updates render immediately and cannot be interrupted; transition updates are lower priority and can be interrupted or deferred — Urgent updates (like typing) are treated as needing an immediate reflection in the UI, while transition-marked updates are explicitly lower priority and can be interrupted by more urgent work, or restarted if a newer transition supersedes them.

[multiple-choice] Why does startTransition(async () => { await x(); setState(y); }) fail to mark the setState as a transition? A. async functions cannot call setState at all B. The setState happens after the callback has already returned, outside the synchronous window React tracks as the transition C. startTransition only works with useState, not useReducer D. await automatically cancels any pending transition

B. The setState happens after the callback has already returned, outside the synchronous window React tracks as the transition — startTransition only marks setState calls made synchronously, during that callback's initial execution, as a transition — a setState that happens later, after an await resolves, runs outside that tracked window and is treated as an ordinary urgent update.

[multiple-choice] What does 'tearing' refer to in the context of concurrent rendering, and how does React avoid it? A. A memory leak caused by too many transitions; avoided by limiting transition count B. Different parts of the UI reflecting inconsistent state at the same moment; avoided by committing a render's output atomically, never piecemeal C. A CSS layout bug caused by useDeferredValue; avoided by disabling animations D. A build-time error from mismatched React versions; avoided by pinning dependencies

B. Different parts of the UI reflecting inconsistent state at the same moment; avoided by committing a render's output atomically, never piecemeal — Tearing means the visible UI shows inconsistent state — some parts reflecting old data, others new — and React's concurrent renderer avoids it by only committing a completed render's full output to the DOM at once, discarding and restarting interrupted renders rather than partially applying them.

Given a component where typing into a search box directly triggers setFilteredResults(expensiveFilter(query)) on every keystroke and feels laggy on a large dataset, refactor it using useTransition so the input stays responsive while the filtered list update becomes interruptible.

const [query, setQuery] = useState(""); const [filteredResults, setFilteredResults] = useState(allItems); const [isPending, startTransition] = useTransition(); function handleChange(value: string) { setQuery(value); startTransition(() => { setFilteredResults(expensiveFilter(value)); }); }

Keep the input's own state update (setQuery) as a normal, urgent setState; wrap only the setFilteredResults call in startTransition.