Skip to content

Concurrent Mode and Transitions

advanced10 min read

The Responsiveness Problem

You know that feeling when you click a tab and nothing happens? The new tab's content requires rendering 500 complex components. Without concurrent features, the entire render blocks the main thread. The tab indicator doesn't update. The cursor doesn't blink. Scroll is dead. For 300ms, your app might as well not exist.

// Without transitions: clicking the tab freezes the UI
function TabContainer() {
  const [activeTab, setActiveTab] = useState('overview');

  return (
    <>
      <TabBar
        active={activeTab}
        onChange={tab => setActiveTab(tab)}  // Blocks UI for 300ms
      />
      <TabContent tab={activeTab} />
    </>
  );
}

The user clicks "Analytics" and nothing happens for 300ms. Then everything updates at once. This feels broken, even though it's technically correct.

The Mental Model

Mental Model

Think of concurrent rendering as working on a draft email while answering phone calls. Without concurrency, you must finish the email before picking up the phone — even if it rings mid-sentence. The caller waits. With concurrency, you can pause the email mid-sentence, answer the phone, then resume the email afterward.

startTransition tells React: "This update is like writing the email — it's important but not urgent. If the phone rings (a user interaction), pause the email and answer the phone first."

The "phone call" is any synchronous update (click, type, submit). The "email" is the transition render. React works on the transition in the background, yielding the main thread for urgent interactions.

useTransition: The API

function TabContainer() {
  const [activeTab, setActiveTab] = useState('overview');
  const [isPending, startTransition] = useTransition();

  function handleTabChange(tab) {
    startTransition(() => {
      setActiveTab(tab); // This render is now interruptible
    });
  }

  return (
    <>
      <TabBar
        active={activeTab}
        onChange={handleTabChange}
        loading={isPending}  // Show loading indicator while transitioning
      />
      {isPending ? (
        <TabSkeleton />
      ) : (
        <TabContent tab={activeTab} />
      )}
    </>
  );
}

useTransition returns:

  • isPending: true while the transition is rendering. Use for loading indicators.
  • startTransition: Wraps state updates to mark them as transitions (lower priority, interruptible).

When the user clicks a tab:

  1. The click handler calls startTransition(() => setActiveTab('analytics'))
  2. React immediately sets isPending to true — the skeleton shows
  3. React starts rendering the new tab content concurrently (on TransitionLane)
  4. If the user clicks a different tab before rendering finishes, React discards the in-progress work and starts the new transition
  5. When the render completes, React commits it and sets isPending to false
Execution Trace
Click:
startTransition(() = setActiveTab('analytics'))
State update queued on TransitionLane
Sync:
isPending = true
React does a synchronous render to show the pending state
Paint:
User sees skeleton
Browser paints the pending UI immediately
Concurrent:
Rendering TabContent
React renders new tab content, yielding every 5ms
Interrupt?:
User types in search
If user interacts, React pauses transition, handles interaction
Complete:
isPending = false
Transition render commits. Skeleton replaced with content

startTransition vs useTransition

There are two ways to create transitions:

import { startTransition, useTransition } from 'react';

// useTransition — in components, gives you isPending
function SearchPage() {
  const [isPending, startTransition] = useTransition();
  // isPending lets you show loading UI
}

// startTransition — anywhere (event handlers, effects, outside React)
function handleSearch(query) {
  startTransition(() => {
    setResults(search(query));
  });
  // No isPending — you need to track loading state yourself
}

Use useTransition when you need a loading indicator. Use startTransition when you just want the update to be interruptible.

What "Interruptible" Really Means

During a concurrent render, React processes fiber nodes one at a time. Between each node, it checks shouldYield(). If 5ms have elapsed, React pauses:

Timeline during a transition render:

|--React renders 5ms--|--Browser event + paint--|--React renders 5ms--|--Browser--|...
   30 fibers processed    Click handled, repaint    30 more fibers

If a synchronous update arrives during a transition render:

|--Transition renders--|  CLICK  |--Sync render + commit--|--Transition restarts--|
   Partial work              ^        Tab click handled         Discarded work restarted
                          Interrupts

The transition work is discarded, not paused. React doesn't save partially rendered state — it starts the transition render from scratch after the synchronous update commits.

Common Trap

Because transitions restart from scratch when interrupted, your component functions might be called more times than you expect. If a user types 5 characters quickly while a transition is rendering, the transition restarts 5 times. Each restart calls your component function again. This is why render must be pure — side effects in render would fire for every restart.

Concurrent UI Patterns

Pattern 1: Show Previous While Loading

By default, transitions keep showing the old UI while the new UI renders in the background:

function SearchResults({ query }) {
  const results = use(fetchResults(query)); // Suspense-enabled fetch

  return (
    <ul>
      {results.map(r => <li key={r.id}>{r.title}</li>)}
    </ul>
  );
}

function SearchPage() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <input
        value={query}
        onChange={e => {
          // Typing is synchronous (immediate feedback)
          // But the results update is a transition
          startTransition(() => {
            setQuery(e.target.value);
          });
        }}
      />
      <Suspense fallback={<Skeleton />}>
        {/* While the transition is pending, React shows the OLD results */}
        {/* not the Suspense fallback */}
        <div style={{ opacity: isPending ? 0.7 : 1 }}>
          <SearchResults query={query} />
        </div>
      </Suspense>
    </>
  );
}

Without transition: typing triggers a new fetch, Suspense shows the skeleton, and the old results disappear. Jarring.

With transition: typing triggers a new fetch, but React keeps showing the old results (dimmed with isPending) until new results are ready. Smooth.

Pattern 2: Optimistic Navigation

function Router() {
  const [page, setPage] = useState('/home');
  const [isPending, startTransition] = useTransition();

  function navigate(url) {
    startTransition(() => {
      setPage(url);
    });
  }

  return (
    <>
      <NavBar
        loading={isPending}
        onNavigate={navigate}
      />
      <Suspense fallback={<PageSkeleton />}>
        <Page url={page} />
      </Suspense>
    </>
  );
}

The nav bar link clicks are immediate — the active link indicator updates synchronously. The page content transitions in the background. If the user rapidly clicks between pages, each click interrupts the previous transition.

Production Scenario: The Autocomplete That Feels Instant

A team builds an autocomplete search that filters 50,000 items client-side:

function Autocomplete({ items }) {
  const [query, setQuery] = useState('');
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    const value = e.target.value;
    setQuery(value); // Sync: input updates immediately

    startTransition(() => {
      // Transition: filtering 50K items and re-rendering the list
      const filtered = items.filter(item =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setFilteredItems(filtered);
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <div className="loading-bar" />}
      <ul>
        {filteredItems.map(item => (
          <SearchResult key={item.id} item={item} />
        ))}
      </ul>
    </>
  );
}

The input always feels responsive because setQuery is synchronous. The list update is a transition — it renders in the background and doesn't block typing. If the user types another character before the list finishes rendering, the old transition is interrupted and a new one starts with the updated query.

How React avoids showing stale content

When a transition is interrupted and restarted, React doesn't commit the incomplete render. The current tree (what's on screen) stays intact. The user sees the old valid content until the new content is fully ready.

This is different from debouncing. With debouncing, you delay the start of work. With transitions, work starts immediately but can be interrupted and restarted. The user gets results as fast as possible — no artificial delay — but never sees an incomplete or inconsistent UI.

Transitions are also different from setTimeout. A setTimeout delays execution by a fixed time regardless of whether the CPU is free. A transition starts immediately and only defers to higher-priority work. On a fast device with no competing work, a transition commits almost instantly.

Common Mistakes

Common Mistakes
  • Wrong: Wrapping the input's own state update in startTransition Right: Keep the input update synchronous. Only wrap the derived/expensive update in transition

  • Wrong: Using transitions for everything to make the app faster Right: Use transitions only for updates where showing stale content temporarily is acceptable

  • Wrong: Expecting isPending to be true immediately inside the event handler Right: isPending updates asynchronously in the next render cycle

  • Wrong: Assuming transitions prevent unnecessary renders Right: Transitions change priority, not whether renders happen. Components still re-render

Challenge

Design the transition boundary

Quiz

Quiz
What does React show during a pending transition when the new content is wrapped in Suspense?

Key Rules

Key Rules
  1. 1startTransition marks state updates as low-priority and interruptible. The UI stays responsive because React yields to the browser between fiber nodes.
  2. 2useTransition returns isPending (boolean) for showing loading indicators, plus the startTransition function.
  3. 3Transitions keep showing old content while new content renders. They avoid hiding already-visible content behind Suspense fallbacks.
  4. 4Interrupted transitions restart from scratch — they don't resume. Render must be pure because it may run multiple times.
  5. 5Synchronous updates (outside startTransition) always take priority and interrupt in-progress transitions.
  6. 6Transitions don't make renders faster — they make renders non-blocking. Use memoization (React.memo, useMemo) to reduce total render work.