Skip to content

Fix: React useTransition Not Working — UI Still Freezes, isPending Never True, or Transition Not Deferred

FixDevs ·

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() resolves

Or the deferred update appears to happen synchronously despite using startTransition:

startTransition(() => {
  setQuery(input);  // Renders synchronously — why isn't this deferred?
});

// The component re-renders immediately

Why 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 legacy ReactDOM.render(). Without concurrent mode, all state updates are synchronous and startTransition has no effect.
  • startTransition doesn’t support async callbacks — the function passed to startTransition must be synchronous. If you pass an async function, the transition completes when the function returns (before any await), not when the async operation finishes.
  • The deferred update must cause expensive renderingstartTransition defers React’s rendering work (reconciliation), not the JavaScript computation that produces the new state. If expensiveFilter(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.

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 your index.js uses createRoot, 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 createRoot

Fix 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 isPending true through await) require React 19. In React 18, transitions complete at the first await.

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 renderstartTransition 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.

For related React performance issues, see Fix: React Too Many Re-renders and Fix: React Memo Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles