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.
Table of Contents
- 1. What the Concurrent Mode problem with expensive state updates means
- 2. The core principle of useTransition
- 3. isPending: a loading state without useState
- 4. The classic use case: tab navigation
- 5. Keeping search inputs responsive
- 6. useTransition with Suspense and Server Components
- 7. useTransition vs. useDeferredValue
- 8. Comparison: blocking vs. non-blocking update
- 9. Limits and common mistakes
- 10. Summary
- 11. FAQ
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.