useTransition: Getting Non-Blocking UI Updates Right
AI generated
</>
{ }
React 18 · useTransition · Concurrent Mode · Performance
useTransition: Non-Blocking UI Updates
done right in React 18

Expensive state updates block the main thread and freeze inputs. useTransition marks updates as low priority so React can render them interruptibly, inputs stay responsive, and isPending signals the loading state without useState overhead.

13 min read startTransition · isPending · Suspense · useDeferredValue React 18+ · Next.js · TypeScript

1. What the Concurrent Mode problem with expensive state updates means

Every state update in React triggers a render. When that render is expensive, because many components re-render, because a large list gets filtered, or because the rendering result requires costly computation, React blocks the main thread for the duration of the render. During that time, input fields respond late, buttons feel frozen, and scroll events go unprocessed. To the user, the application feels sluggish.

The problem gets worse in interactive scenarios: a search input that filters a large list on every keystroke. A tab switch that builds an elaborate data view. A filter panel that resorts hundreds of products. In all of these cases, the expensive state update competes with input events for the main thread. useTransition solves this problem by letting React interrupt the expensive update when urgent updates, such as keystrokes, come in. That is the core of the Concurrent Mode promise: prioritized rendering instead of first come, first served.

2. The core principle of useTransition

useTransition returns a tuple: [isPending, startTransition]. The startTransition function takes a callback. State updates inside that callback are marked as a transition, low-priority updates that React is allowed to interrupt and finish later. React starts the render immediately, but if a higher-priority update comes in (a keystroke event, a click), React interrupts the transition render, processes the urgent update synchronously, and resumes the transition render afterward.

It is important to understand: the currently displayed state remains visible for as long as the transition is running. React does not show the incomplete intermediate state, the previous UI stays in place. Only isPending is set to true to signal the ongoing transition. Once the transition completes, React switches to the new state atomically. That is fundamentally different from a direct setState, which would immediately make a partially rendered state visible.


// useTransition basic pattern, non-blocking tab switch
import { useState, useTransition } from 'react';
import { HeavyTabContent } from './HeavyTabContent'; // expensive render

const TABS = ['Products', 'Statistics', 'Reports'] as const;
type Tab = typeof TABS[number];

export function TabPanel() {
  const [activeTab, setActiveTab] = useState<Tab>('Products');
  const [isPending, startTransition] = useTransition();

  const handleTabClick = (tab: Tab) => {
    // Wrap in startTransition, marks this as low-priority
    startTransition(() => {
      setActiveTab(tab); // React renders HeavyTabContent in background
    });
  };

  return (
    <div>
      <nav>
        {TABS.map(tab => (
          <button
            key={tab}
            onClick={() => handleTabClick(tab)}
            style={{
              fontWeight: activeTab === tab ? 'bold' : 'normal',
              opacity: isPending ? 0.7 : 1, // visual feedback during transition
            }}
          >
            {tab}
          </button>
        ))}
      </nav>
      {/* Old content stays visible until transition completes */}
      <div style={{ opacity: isPending ? 0.5 : 1 }}>
        <HeavyTabContent tab={activeTab} />
      </div>
      {isPending && <span>Loading...</span>}
    </div>
  );
}

3. isPending: a loading state without useState

isPending is the second element of the useTransition tuple and, in many cases, replaces a separate loading state. It is automatically set to true as soon as a transition starts and returns to false once the transition render has completed and committed. That means no manual setLoading(true) before the update, no setLoading(false) afterward, and no risk of the loading state getting stuck after an error.

isPending is excellent for subtle visual feedback: reduced opacity on the content, a spinner in the navigation bar, a disabled state on the active tab button. For more aggressive loading visuals, a full loading screen that completely replaces the previous UI, isPending is less suitable, because the previous UI stays visible by design. In that case, Suspense with a fallback is the better choice.

4. The classic use case: tab navigation

Tab navigation is the textbook case for useTransition. Without a transition, an expensive tab content render blocks the thread until the new content is fully rendered. Repeated clicks on tabs during that time get queued up, producing a jerky sequence of state changes. With useTransition, React can treat each new tab click as urgent and abandon the running tab render to jump straight to the most recently clicked tab, without rendering the intermediate stages. For the user, that means the last-clicked tab appears without delay caused by all the previous clicks.

Another detail that useTransition improves for tab navigation: since the old UI stays visible while the new one is loading, there is no layout-shift problem. Classic implementations often show a brief empty area during a tab switch before the new content appears. With useTransition, the old content stays visible until the new one is fully ready, a noticeably cleaner visual transition.

5. Keeping search inputs responsive

Search inputs with useTransition follow the pattern "urgent: update the input, not urgent: filter the results". The user's keystrokes in the search field are high priority, any delay here is immediately noticeable. Filtering the result list is an expensive operation that can be deprioritized without the user perceiving it as disruptive. The implementation separates the two state variables: the input state is set directly (synchronously), the search state used for filtering is wrapped in startTransition.

The alternative, useDeferredValue, works similarly but is conceptually different: useTransition marks the update call itself as low priority, while useDeferredValue supplies the receiver with a delayed copy of a value. For search inputs, useTransition is the better fit when the filter operation itself lives inside its own setState call. useDeferredValue is better when you want to slow down an external prop or a value whose update call you do not control.


// Reactive search input with useTransition, input stays fast
import { useState, useTransition, useMemo } from 'react';

interface Product { id: number; name: string; category: string; }

export function ProductSearch({ products }: { products: Product[] }) {
  const [inputValue, setInputValue] = useState('');
  const [searchQuery, setSearchQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setInputValue(value); // urgent: input updates immediately

    startTransition(() => {
      setSearchQuery(value); // non-urgent: filter list in transition
    });
  };

  // Expensive filter, runs in transition context
  const filtered = useMemo(
    () => products.filter(p =>
      p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
      p.category.toLowerCase().includes(searchQuery.toLowerCase())
    ),
    [products, searchQuery]
  );

  return (
    <div>
      <input
        value={inputValue}
        onChange={handleChange}
        placeholder="Search products..."
      />
      {isPending && <span>Searching...</span>}
      <ul style={{ opacity: isPending ? 0.6 : 1 }}>
        {filtered.map(p => (
          <li key={p.id}>{p.name} ({p.category})</li>
        ))}
      </ul>
      <p>{filtered.length} Ergebnisse</p>
    </div>
  );
}

6. useTransition with Suspense and Server Components

In React 18, useTransition and Suspense work closely together. When a component inside a transition boundary needs to load something, for instance via React.lazy() or through a Suspense boundary waiting on data, the transition stays in the pending phase until the component is ready. The Suspense fallback is not shown while the transition is still running and a boundary is already present, instead, the previous UI stays visible. Only once the transition timeout is exceeded does React display the Suspense fallback.

With Next.js 15 and React Server Components, useTransition becomes even more relevant: server action calls run inside transitions, router navigations can be marked as transitions, and loading Server Component data, which internally relies on Suspense, stays seamless for the user. startTransition is also available as a standalone import from React, without the isPending state, for cases where the loading state does not need to be displayed.

7. useTransition vs. useDeferredValue

useTransition and useDeferredValue solve the same problem, deprioritizing expensive updates, from different angles. useTransition operates on the source of the update: you wrap the setState call in startTransition, marking the state change itself as low priority. useDeferredValue operates on the receiver: you give it a value, and it returns a delayed copy that only updates once the browser has spare time.

useTransition is the right choice when you control the update call yourself, for example inside your own event handler. useDeferredValue is the right choice when you receive a value whose source you do not control, such as a prop or context value that changes quickly and whose use in an expensive component you want to throttle. Both hooks can be combined, but it is rare that both are necessary at the same time.

8. Comparison: blocking vs. non-blocking update

The measurable difference between a blocking and a non-blocking state update shows up most clearly in input latency: the time between a keystroke and the visible character in the input field. With a blocking filter update that takes 200 ms, the input can appear delayed by up to 200 ms. With useTransition, the keystroke is always processed immediately, while the filter render is allowed to be interrupted.

Property Direct setState startTransition(setState)
Input responsiveness Blocked for render duration Always immediately responsive
Visible intermediate state Yes (brief empty area) No (old UI stays)
Rapid clicking All states get rendered Only the last state
Loading state Manual via useState Automatic via isPending
Suspense integration Shows fallback immediately Holds old UI until ready

9. Limits and common mistakes

The most common mistake with useTransition is wrapping non-state updates in startTransition. startTransition expects a synchronous callback containing state update calls. Writing an asynchronous callback or a side-effectful call (DOM mutation, fetch) inside startTransition produces undefined behavior. In React 19, startTransition is extended to support async callbacks, but only for the new server action pattern, not as a generic async gateway.

A second common mistake: controlled inputs. When an input's value is bound directly to a transition state, a noticeable input delay results, because the input value only updates after the transition. The correct solution is the dual-state pattern already described: input state synchronous, search state inside the transition. useTransition is also not a substitute for memoization with useMemo and React.memo, transitions help with scheduling, not with skipping expensive computations when the inputs have not changed.


// Common mistake: async in startTransition (React 18)
// WRONG, async not supported in React 18 startTransition
const [isPending, startTransition] = useTransition();

startTransition(async () => {
  const data = await fetchData(); // breaks transition semantics
  setData(data);
});

// RIGHT, keep transition sync, handle async separately
startTransition(() => {
  // Only synchronous setState calls here
  setIsLoading(true);
  setFilter(newFilter); // sync update
});

// Fetch outside transition, update state inside
async function handleFilterChange(filter: string) {
  startTransition(() => setFilter(filter)); // sync UI update

  // Async fetch after transition for data loading
  const result = await fetchFilteredData(filter);
  startTransition(() => {
    setData(result);    // another transition for the data update
    setIsLoading(false);
  });
}

// WRONG: controlled input bound to transition state
const [query, setQuery] = useState('');
const [isPending2, startTransition2] = useTransition();
<input value={query} onChange={e => startTransition2(() => setQuery(e.target.value))} />
// Input lags because value is tied to transition state

// RIGHT: separate input value from query state
const [inputValue, setInputValue] = useState('');
const [queryState, setQueryState] = useState('');
<input value={inputValue} onChange={e => {
  setInputValue(e.target.value);             // sync: input stays reactive
  startTransition(() => setQueryState(e.target.value)); // deferred: filter
}} />

10. Summary

useTransition is the tool for taking expensive state updates out of the urgent render path. The basic pattern is simple: wrap every state update that triggers an expensive render cascade in startTransition. The UI stays responsive because React is always allowed to prioritize urgent updates, keystrokes, clicks. isPending gives you the loading state for free. Rapid clicking only renders the last state, because React discards transition renders when a newer update arrives.

The most important limits: only synchronous state updates inside startTransition. Never bind controlled inputs to transition state. useTransition is not a substitute for memoization, but a scheduling tool. For values whose source you do not control, useDeferredValue is the alternative. For search inputs, tab navigation, filter panels, and any other case where an expensive update follows user input, useTransition is the most direct solution and should be considered before debouncing and other workarounds.

useTransition, the essentials at a glance

Non-blocking updates

startTransition marks state updates as low priority, React is allowed to interrupt them when urgent updates come in.

Free loading state

isPending is automatically true during the transition, no manual loading state, no risk of it getting stuck.

Old UI stays visible

No empty intermediate state. React shows the previous UI until the transition is fully complete, then an atomic switch.

Only synchronous updates

startTransition expects a synchronous callback. No async calls, no side effects, place only setState calls inside it.

11. FAQ: useTransition in React 18

1What does useTransition do?
State updates inside startTransition are deprioritized, React interrupts them for urgent updates such as keystrokes. isPending signals the ongoing transition.
2useTransition vs. useDeferredValue?
useTransition: source of the update (setState call). useDeferredValue: receiver (delayed copy of a value). If you control the update call, use useTransition.
3Is async possible in startTransition?
In React 18: no. Only synchronous setState calls. Supported for server actions in React 19, but not as a generic async gateway.
4Why does the old UI stay visible?
React shows no intermediate state. The previous UI stays until the transition is fully complete. Prevents layout shifts and empty intermediate states.
5Controlled inputs with useTransition?
Never bind an input value directly to transition state. Dual-state pattern: input state synchronous, filter state inside startTransition.
6Multiple rapid clicks on tabs?
React discards running transition renders on new updates. Only the last click actually gets rendered, the intermediate stages are skipped.
7Substitute for debouncing?
Partially. useTransition prevents blocking without artificial delays. Debouncing reduces the number of updates. Both complement each other, useTransition is the more native solution.
8useTransition with Suspense?
No fallback during a running transition with an existing boundary. The old UI stays until all the data is ready, no premature spinner.
9Since which React version?
Stable since React 18. Concurrent Mode scheduling was not available in earlier versions.
10Does it replace useMemo and React.memo?
No. useTransition = scheduling (when rendering happens). useMemo/React.memo = memoization (whether rendering happens). Both complement each other and do not replace one another.