Fix: React useTransition Not Working — UI Still Freezes, isPending Never True, or Transition Not Deferred
Part of: React & Frontend Errors
Quick Answer
How to fix React useTransition and startTransition issues — what counts as a transition, Suspense integration, concurrent rendering requirements, and common mistakes that prevent transitions from deferring.
The Problem
useTransition is called but the UI still freezes during heavy state updates:
const [isPending, startTransition] = useTransition();
function handleSearch(query) {
startTransition(() => {
setResults(expensiveFilter(data, query)); // UI still freezes
});
}Or isPending is immediately false and never shows a loading state:
const [isPending, startTransition] = useTransition();
startTransition(async () => {
const data = await fetchData(); // async — transition completes immediately
setData(data);
});
// isPending goes false before fetchData() resolvesOr the deferred update appears to happen synchronously despite using startTransition:
startTransition(() => {
setQuery(input); // Renders synchronously — why isn't this deferred?
});
// The component re-renders immediatelyWhy This Happens
useTransition works with React’s concurrent rendering model, which has specific requirements:
- Only works in concurrent mode — you must render your app with
createRoot(), not the legacyReactDOM.render(). Without concurrent mode, all state updates are synchronous andstartTransitionhas no effect. startTransitiondoesn’t supportasynccallbacks — the function passed tostartTransitionmust be synchronous. If you pass an async function, the transition completes when the function returns (before anyawait), not when the async operation finishes.- The deferred update must cause expensive rendering —
startTransitiondefers React’s rendering work (reconciliation), not the JavaScript computation that produces the new state. IfexpensiveFilter(data, query)runs in the callback, it still blocks the thread synchronously. - Wrapping a synchronous update doesn’t help if rendering is fast — if the state update causes a fast render, there’s nothing to defer. Transitions are only visible as improvements when the render tree is genuinely complex.
What useTransition actually does is mark a state update as low priority. React keeps two queues of pending work: urgent (typing into an input, clicks, focus changes) and transition (everything you pass through startTransition). When both have pending work, React renders the urgent batch first, then yields back to the browser to paint and process events, then comes back to finish the transition. The interruption point is React’s scheduler — under the hood, React calls MessageChannel.postMessage to give the browser a chance to handle other work every ~5ms. If your component’s render itself does not yield (a single render pass that takes 200ms in a tight loop), there is nothing to interrupt and the UI still freezes for that duration.
The mental model worth holding is that useTransition is not about making rendering faster — it is about making rendering interruptible. The total time to produce the next frame is the same or slightly worse (interruption has overhead), but the user-facing experience improves because typing stays responsive, the existing UI stays visible during loads, and React can show stale content while new content prepares in the background. This is also why misuse is so common: developers wrap an expensive JavaScript computation expecting it to be deferred, but the deferral applies only to the React render that consumes the resulting state. The computation runs to completion synchronously inside startTransition before React even schedules the transition.
How Other Tools Handle This
Concurrent rendering is increasingly a cross-framework concern, and each major library has staked out a different position on how to express deferred work.
React useTransition vs Vue Suspense. Vue does not have a direct useTransition equivalent because Vue’s update model is already fine-grained: a component re-renders only when its reactive dependencies change. The closest analog is <Suspense> plus async setup functions. Vue 3.4+ added experimental defineAsyncComponent with controlled fallback timing, and Vue’s transition system focuses more on CSS animations between states than on render scheduling. For “show the old UI while the new one loads,” Vue developers typically use a manual isLoading ref and a guard in the template.
React useTransition vs SvelteKit deferred load. SvelteKit has load functions that return data for routes, and you can mark some properties of the returned object as deferred promises. The page renders with the available data immediately and streams in the rest as the promises resolve, with {#await} blocks expressing the fallback. There is no “isPending” hook because Svelte’s reactivity already tracks which parts of the page depend on the pending promise.
React useTransition vs Solid Transition. Solid has useTransition() with an API that intentionally mirrors React’s: const [pending, startTransition] = useTransition(). The semantics differ slightly because Solid never re-renders components — it updates only the reactive nodes that depend on changed signals. A Solid transition defers signal updates, not component re-renders, which often produces more obvious wins because the framework already has finer-grained reactivity. Solid transitions also natively support async work without the React 18 vs 19 split.
React useTransition vs Qwik resumability. Qwik takes the opposite approach: instead of optimizing client-side rendering, it serializes server-rendered HTML plus a continuation of the application state, and “resumes” interactivity lazily as the user interacts. There is no concept of a long render to defer because Qwik aims for zero JavaScript on initial load. The transition you would express with useTransition in React is often implicit in Qwik — clicking a link triggers a server round-trip that returns a chunk of HTML and a tiny amount of JS, and Qwik replaces the relevant portion of the page without re-rendering the whole tree.
Loading-state UX across frameworks. All five frameworks recognize the same UX problem: do not flash a loading spinner when the new content will arrive in 50ms. React’s solution is useTransition plus Suspense, which keeps old content visible until new content is ready or until React decides to fall back to the suspense boundary. SvelteKit’s deferred load achieves the same effect through streaming. Vue uses a manual debounce on the spinner display. Solid mirrors React’s pattern. Qwik avoids the problem by not having a separate “loading” client state at all. When porting code between frameworks, the trickiest concept to translate is “stale content is shown intentionally” — that explicit intent does not always survive the translation.
Fix 1: Verify You’re Using Concurrent Mode
useTransition requires createRoot():
// WRONG — legacy render mode, useTransition has no effect
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));
// CORRECT — concurrent mode required for useTransition to work
import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')).render(<App />);Note: All React apps using
react-dom/client(the default since React 18) use concurrent mode automatically. If yourindex.jsusescreateRoot, you’re already in concurrent mode.
Check for mixed mode:
// If you see this in any part of your app, transitions may not work correctly
import ReactDOM from 'react-dom';
ReactDOM.render(...); // This is the legacy API — upgrade to createRootFix 2: Keep startTransition Callbacks Synchronous
startTransition expects a synchronous function. For async operations, set the pending state manually:
// WRONG — async function, isPending goes false immediately
const [isPending, startTransition] = useTransition();
async function handleSearch(query) {
startTransition(async () => {
const results = await fetchResults(query); // transition ends here at 'await'
setResults(results);
});
// isPending is already false by the time fetchResults resolves
}
// CORRECT — use startTransition only for the synchronous state update
// Use separate loading state for async operations
const [isPending, startTransition] = useTransition();
const [isLoading, setIsLoading] = useState(false);
async function handleSearch(query) {
setIsLoading(true);
try {
const results = await fetchResults(query);
startTransition(() => {
setResults(results); // Defer the (potentially expensive) render
});
} finally {
setIsLoading(false);
}
}Use startTransition from React directly for async flows with use:
// React 19+ — async transitions with Actions
// startTransition now supports async functions in React 19
import { useTransition } from 'react';
function SearchForm() {
const [isPending, startTransition] = useTransition();
async function handleSearch(formData) {
startTransition(async () => {
// In React 19, async transitions keep isPending true until completion
const results = await fetchResults(formData.get('query'));
setResults(results);
});
}
return (
<form action={handleSearch}>
<input name="query" />
<button disabled={isPending}>
{isPending ? 'Searching...' : 'Search'}
</button>
</form>
);
}Note: Async transitions (keeping
isPendingtrue throughawait) require React 19. In React 18, transitions complete at the firstawait.
Fix 3: Use Deferred Rendering for Expensive Renders
startTransition defers React rendering, not JavaScript computation. Move expensive computation outside the transition:
// WRONG — expensiveFilter runs synchronously inside the transition callback
// It blocks the thread before React even starts rendering
function handleSearch(query) {
startTransition(() => {
const filtered = expensiveFilter(data, query); // Still blocks JS thread
setFilteredData(filtered);
});
}
// CORRECT — move computation outside, defer only the state update
function handleSearch(query) {
// This still runs synchronously, but React can interrupt the resulting render
const filtered = expensiveFilter(data, query); // Compute first
startTransition(() => {
setFilteredData(filtered); // Now React can defer and interrupt this render
});
}
// BETTER — use useDeferredValue to defer based on the query value
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => expensiveFilter(data, deferredQuery), [deferredQuery]);
return (
<div style={{ opacity: query !== deferredQuery ? 0.7 : 1 }}>
{results.map(r => <ResultItem key={r.id} result={r} />)}
</div>
);
}Fix 4: Understand useTransition vs useDeferredValue
Both APIs enable concurrent rendering, but for different use cases:
// useTransition — you control when the transition starts
// Best for: event handlers, button clicks, explicit user actions
function TabSwitcher() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState('home');
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab); // Defer the expensive tab render
});
}
return (
<>
<TabButton onClick={() => selectTab('home')} pending={isPending}>Home</TabButton>
<TabButton onClick={() => selectTab('posts')} pending={isPending}>Posts</TabButton>
<TabContent tab={tab} />
</>
);
}
// useDeferredValue — React decides when to defer
// Best for: props you receive from outside, debounce-like behavior
function SearchPage({ query }) {
// query updates immediately in the URL/input
// deferredQuery lags behind — renders with stale value while new value is rendering
const deferredQuery = useDeferredValue(query);
return (
<>
<SearchInput value={query} /> {/* Always current */}
<Suspense fallback={<Spinner />}>
<SearchResults query={deferredQuery} /> {/* Can lag */}
</Suspense>
</>
);
}Visual indicator for stale content:
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div style={{
opacity: isStale ? 0.5 : 1,
transition: 'opacity 0.2s',
pointerEvents: isStale ? 'none' : 'auto'
}}>
<ResultsList query={deferredQuery} />
</div>
);
}Fix 5: Combine with Suspense for Data Loading
useTransition integrates with Suspense to avoid showing fallbacks on updates:
import { Suspense, useState, useTransition } from 'react';
function App() {
const [userId, setUserId] = useState(1);
const [isPending, startTransition] = useTransition();
function showUser(id) {
startTransition(() => {
setUserId(id);
// Without startTransition: React shows the Suspense fallback immediately
// With startTransition: React keeps showing the old content while loading new
});
}
return (
<>
<button onClick={() => showUser(2)} disabled={isPending}>
{isPending ? 'Loading...' : 'Show User 2'}
</button>
{/* isPending lets you add your own loading indicator */}
{isPending && <div className="overlay-spinner" />}
<Suspense fallback={<UserSkeleton />}>
<UserProfile userId={userId} />
{/* With startTransition: stays on old profile until new one is ready */}
{/* Without startTransition: shows UserSkeleton while fetching */}
</Suspense>
</>
);
}Data fetching with Suspense-compatible libraries:
// React Query — automatically integrates with transitions
import { useSuspenseQuery } from '@tanstack/react-query';
function UserProfile({ userId }) {
// useSuspenseQuery suspends until data is ready
const { data: user } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
return <div>{user.name}</div>;
}
// Parent uses startTransition — keeps old UI visible while new data loads
function App() {
const [userId, setUserId] = useState(1);
const [isPending, startTransition] = useTransition();
return (
<>
<button onClick={() => startTransition(() => setUserId(2))}>
Next User
</button>
<Suspense fallback={<Skeleton />}>
<UserProfile userId={userId} />
</Suspense>
</>
);
}Fix 6: Diagnose Transitions with React DevTools
Use React DevTools Profiler to confirm transitions are working:
// Wrap your component with Profiler to measure render time
import { Profiler } from 'react';
function onRenderCallback(id, phase, actualDuration) {
console.log(`${id} [${phase}] took ${actualDuration.toFixed(2)}ms`);
}
<Profiler id="SearchResults" onRender={onRenderCallback}>
<SearchResults query={deferredQuery} />
</Profiler>Common patterns to verify:
// Pattern: urgent update + deferred update
function SearchInput({ onSearch }) {
const [inputValue, setInputValue] = useState('');
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const value = e.target.value;
// Urgent: input updates immediately (not wrapped in transition)
setInputValue(value);
// Deferred: search results update after input is responsive
startTransition(() => {
onSearch(value);
});
}
return (
<div>
<input
value={inputValue}
onChange={handleChange}
placeholder="Search..."
/>
{isPending && <span>Updating results...</span>}
</div>
);
}Still Not Working?
isPending is true indefinitely — this usually means the deferred render is never completing. Check for infinite render loops inside the deferred component (a state update on every render), or an error being thrown and caught by an error boundary. If a component inside the transition throws, isPending stays true.
startTransition doesn’t defer on first render — startTransition only defers subsequent renders after the component has mounted. On initial load, all renders are synchronous. To defer the initial render, use lazy() with Suspense to code-split the component.
Third-party state management with transitions — state updates through Redux, Zustand, or Jotai are not automatically treated as transitions. Wrap the dispatch/setter inside startTransition:
// Zustand + transitions
const setFilter = useStore(state => state.setFilter);
const [isPending, startTransition] = useTransition();
function handleFilter(value) {
startTransition(() => {
setFilter(value); // Zustand update treated as transition
});
}Transitions don’t help if the render itself is O(n²) — useTransition gives React the ability to interrupt and resume renders, but it doesn’t make individual renders faster. If a single render pass through your component tree takes 500ms, interruption doesn’t help — you need to virtualize the list with react-virtual or @tanstack/react-virtual instead.
useEffect inside a transitioned component fires after commit, not after start — if you depend on a side effect to confirm a transition completed, remember that useEffect only runs after the transition’s render is committed to the DOM. isPending flips back to false slightly before that effect runs. Use useLayoutEffect if you need to measure the post-commit DOM during a transition, but be aware that layout effects block painting and can negate the responsiveness win.
Transitions race with React Strict Mode double-rendering — under Strict Mode in development, React invokes components twice. A startTransition call inside the body of a component (rather than inside an event handler) can produce two transitions, two isPending toggles, and confusing console logs. Always wrap startTransition calls in event handlers or effects, never in the render body.
Server Components do not have transitions — React Server Components run at request time and produce a serialized payload, not a render that can be interrupted. useTransition is a client-side hook and works only inside 'use client' boundaries. If you are looking to defer a server render, the equivalent is streaming with <Suspense> and Next.js loading.tsx files.
For related React performance issues, see Fix: React Too Many Re-renders, Fix: React Suspense Not Triggering, Fix: React Compiler Not Working, and Fix: React Memo Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.
Fix: Blurhash Not Working — Placeholder Not Rendering, Encoding Failing, or Colors Wrong
How to fix Blurhash image placeholder issues — encoding with Sharp, decoding in React, canvas rendering, Next.js image placeholders, CSS blur fallback, and performance optimization.
Fix: Embla Carousel Not Working — Slides Not Scrolling, Autoplay Not Starting, or Thumbnails Not Syncing
How to fix Embla Carousel issues — React setup, slide sizing, autoplay and navigation plugins, loop mode, thumbnail carousels, responsive breakpoints, and vertical scrolling.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.