EmpireUI
Get Pro
← Blog8 min read#concurrent#react#useTransition

React Concurrent Rendering: useTransition, useDeferredValue Explained

Learn how React's useTransition and useDeferredValue hooks fix janky UIs by letting you control rendering priority — with real code examples you can ship today.

Code editor screen showing JavaScript React code with colorful syntax highlighting

What Concurrent Rendering Actually Is

React 18, shipped in March 2022, changed how the renderer works at a fundamental level. Before that, rendering was synchronous and blocking — React grabbed the thread, did its thing, and you waited. Every update, no matter how trivial, had the same priority as a user typing in a search box.

Concurrent rendering breaks that model. React can now start rendering, pause, throw work away entirely, and resume later. It's not multithreading — JavaScript is still single-threaded — but React can yield control back to the browser between units of work. That 16ms frame budget your browser has? React actually respects it now.

The practical result is that expensive renders no longer freeze your UI. React decides what's urgent (user input, animations) and what can wait (filtering a 10,000-item list, lazy-loading a heavy component). You get to participate in that decision with two hooks: useTransition and useDeferredValue.

Worth noting: concurrent features are opt-in. You don't get them just by upgrading to React 18 — you have to either use the new APIs or render with createRoot. Legacy ReactDOM.render stays in sync mode forever.

useTransition: Marking Work as Non-Urgent

useTransition returns two things: a boolean isPending and a function startTransition. You wrap any state update inside startTransition, and React treats that update as low priority. The UI stays responsive while React works through the expensive re-render in the background.

Here's the classic example — a search filter over a huge list: ``jsx import { useState, useTransition } from 'react'; function SearchPage({ items }) { const [query, setQuery] = useState(''); const [filteredItems, setFilteredItems] = useState(items); const [isPending, startTransition] = useTransition(); function handleSearch(e) { const value = e.target.value; setQuery(value); // urgent — update input immediately startTransition(() => { // non-urgent — React can defer this setFilteredItems(items.filter(item => item.name.toLowerCase().includes(value.toLowerCase()) )); }); } return ( <div> <input value={query} onChange={handleSearch} placeholder="Search..." /> {isPending && <span style={{ opacity: 0.5 }}>Updating...</span>} <ul> {filteredItems.map(item => <li key={item.id}>{item.name}</li>)} </ul> </div> ); } ``

The isPending flag is gold for UX. You can show a spinner, reduce opacity, or display a skeleton — whatever fits your design system. Honestly, this alone justifies adopting React 18. Users see the input update immediately at 60fps while the list catches up half a second later. That feels fast even when the underlying work is slow.

One more thing — startTransition doesn't accept async functions directly. If you're fetching data, you need to kick off the fetch synchronously and then set state in the .then() inside the transition. React 19 is addressing this with useActionState, but for React 18 that's the pattern.

In practice, the 300ms you save on perceived input lag is worth more than a 50ms improvement to raw render time. Users feel responsiveness, they don't measure it.

useDeferredValue: The Value-Level Alternative

useDeferredValue is the other half of the concurrent API. Instead of wrapping a state setter, you wrap a value. React will use the previous value first, then quietly re-render with the new one when the thread is free. Think of it as a built-in debounce that's smarter than setTimeout — it doesn't have a fixed delay, it defers exactly as long as necessary.

import { useState, useDeferredValue, memo } from 'react';

// Memoize the expensive list so it only re-renders when deferredQuery changes
const ExpensiveList = memo(function ExpensiveList({ query }) {
  // Simulate slow filtering
  const filtered = hugeItemList.filter(item =>
    item.includes(query)
  );
  return <ul>{filtered.map(i => <li key={i}>{i}</li>)}</ul>;
});

function App() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
      />
      {/* This renders immediately with the current query */}
      <p>Searching for: {query}</p>
      {/* This re-renders at low priority with the deferred value */}
      <ExpensiveList query={deferredQuery} />
    </div>
  );
}

That memo wrapper is not optional. Without it, useDeferredValue does nothing useful — the component re-renders synchronously anyway because React has no way to bail out. Pair useDeferredValue with memo (or React.memo) and you actually get the deferred behavior.

Quick aside: you can detect when the UI is showing stale deferred content by comparing query !== deferredQuery. Use that to add a visual indicator — a 0.5 opacity overlay, a subtle 2px blue border, whatever keeps users oriented. Don't just silently show stale data with no feedback.

So when do you pick useDeferredValue over useTransition? Use useTransition when you control the state setter. Use useDeferredValue when you receive a value as a prop and you don't own the setter — like when a parent component drives the query string.

The Difference Between Them (And When It Actually Matters)

People mix these up constantly. Here's the mental model: useTransition is imperative — you decide exactly which state update is non-urgent. useDeferredValue is declarative — you hand React a value and say "use the previous one while you're figuring out the new one." They're solving the same problem from different angles.

If you control the state, useTransition is cleaner. You get the isPending flag for free, and you can wrap multiple state updates in a single startTransition to batch them at low priority together. That matters when a search triggers both a list filter and a sidebar update — you want those to re-render together, not stagger.

// Batching multiple low-priority updates
startTransition(() => {
  setFilteredItems(filtered);
  setRelatedTags(tags);
  setResultCount(filtered.length);
  // All three are deferred and batched — one render pass
});

That said, useDeferredValue shines in design system components where you're building something reusable. Imagine you're building a data table for a component library — you'd accept a data prop and internally defer it. Consumers don't need to know about transitions at all. That's a clean API boundary.

Look, neither hook replaces virtualization for truly massive lists. If you're rendering 50,000 rows, you still need react-window or @tanstack/virtual. Concurrent rendering helps with expensive-but-finite renders, not infinite-DOM problems. Know the difference or you'll ship a UI that defers its way into a 4-second blank screen.

Real-World Patterns You Can Steal

Tab switching is the best use case nobody talks about. When a user clicks between tabs that each render heavy content, the tab label should update instantly while the content fades in. Wrap the content state update in startTransition, show isPending as a skeleton, done. Users perceive zero lag on the click.

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

  function switchTab(tab) {
    startTransition(() => setActiveTab(tab));
  }

  return (
    <div>
      <nav>
        {['overview', 'analytics', 'settings'].map(tab => (
          <button
            key={tab}
            onClick={() => switchTab(tab)}
            style={{
              fontWeight: activeTab === tab ? 'bold' : 'normal',
              // Tab label updates instantly, no transition wrapping here
            }}
          >
            {tab}
          </button>
        ))}
      </nav>
      <div style={{ opacity: isPending ? 0.6 : 1, transition: 'opacity 150ms' }}>
        <TabContent tab={activeTab} />
      </div>
    </div>
  );
}

Another pattern worth stealing: progressive search results. Show the first 20 results immediately, then let the full list come in via a deferred value. Users see something in under 100ms, and the complete results arrive shortly after. It's a fake-it-till-you-make-it approach that works well for perceived performance.

If you're building UI-heavy applications — say, ones with lots of animated components or style-switching — this pairs really well with lightweight component libraries. When you're working with something like Empire UI's glassmorphism components that already handle animation and visual states, you want your data layer and your render layer to stay separate concerns. useTransition keeps expensive data updates from blocking those smooth style transitions.

One more thing — combine these hooks with Suspense for data fetching and you get the full concurrent picture. The hooks handle rendering priority; Suspense handles loading states. They compose cleanly. React's architecture in 2026 expects you to use all three together.

Performance Profiling: Seeing It Work

You can't just trust that these hooks are helping — profile before and after. Open React DevTools Profiler (version 4.x and above has a Timeline view), record an interaction, and look for the colored bars. Orange bars are commits. If you had a 400ms orange block and now you have several small ones with gaps, the transition is working.

The browser's Performance tab is equally useful. Without useTransition, a 300ms filter render shows up as one long task blocking the main thread. With it, you'll see React yielding every 5ms, yielding back to the browser to handle input events. That's the 5ms yield window React's scheduler targets internally.

// Quick way to measure — before and after
console.time('filter');
startTransition(() => {
  setFilteredItems(items.filter(i => i.name.includes(query)));
});
// Note: time() here measures scheduling, not actual render
// Use React Profiler for render timing
console.timeEnd('filter');

Worth noting: isPending doesn't become true instantly in every case. React may decide the update is fast enough to render synchronously anyway. Don't build UX that requires isPending to always flip — treat it as a hint, not a guarantee. Your loading skeleton should also look fine briefly appearing and disappearing.

If you're working on polished UI components that need smooth 60fps behavior, pair this profiling work with checking your CSS animations too. Heavy box shadows and blur effects — like those in the glassmorphism generator — can add GPU composite cost that makes transitions feel slower than they are. Profile both JS and paint together.

Common Mistakes That Will Bite You

Putting everything in startTransition is the most common mistake. If you wrap user input state in a transition, the input will lag because React defers it. Only wrap the expensive downstream updates — never the thing the user is directly controlling. The input value is always urgent.

Forgetting memo with useDeferredValue is a close second. This one's subtle because the code doesn't error — it just doesn't work. You'll spend 45 minutes wondering why your deferred value isn't helping, then realize the component re-renders eagerly anyway. Always memo the consumer.

// WRONG — deferredValue does nothing useful here
function BadExample({ query }) {
  const deferred = useDeferredValue(query);
  return <ExpensiveList query={deferred} />; // ExpensiveList is not memoized
}

// RIGHT
const ExpensiveList = memo(function ExpensiveList({ query }) {
  // now React can skip re-rendering when deferred value hasn't changed yet
  return <ul>{/* ... */}</ul>;
});

Using these hooks as a substitute for fixing genuinely slow renders is the third trap. If your component takes 800ms to render because you're doing a nested loop with O(n²) complexity, useTransition makes it non-blocking, but users still wait 800ms. Fix the algorithm first, then use transitions for the remaining rough edges.

Honestly, the biggest mistake is not using these hooks at all. If you're on React 18 or 19 and you have a search input or a tab switch that feels sticky, you're leaving a free UX win on the table. These APIs are stable, well-tested, and the mental model clicks after one implementation. There's no good reason to still be in sync-mode for expensive updates.

FAQ

Does useTransition work with async/await?

Not directly in React 18 — startTransition must be synchronous. Start the async operation outside the transition and call setState inside the .then() within startTransition. React 19's useActionState handles async transitions natively.

Is useDeferredValue the same as debouncing with setTimeout?

No — setTimeout uses a fixed delay regardless of system load. useDeferredValue defers exactly as long as the browser needs to stay responsive, which is adaptive. It's smarter but also less predictable, so don't use it when you need exact timing.

Do I need both hooks, or can I pick one?

Pick based on what you own. If you control the state setter, use useTransition. If you're receiving a prop value from a parent, use useDeferredValue. They're not interchangeable — they solve the same problem from different ownership boundaries.

Will these hooks help with React Server Components?

useTransition works on the client and can wrap router navigations in Next.js App Router, which triggers RSC fetches. useDeferredValue is client-only. Neither applies to server-side render logic itself.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

useTransition in React: Non-Blocking Updates and Concurrent RenderingReact Error Boundaries: Catching Crashes Without Losing Your MindStepper Component in React: Multi-Step Forms and OnboardingSkeleton Loader in React: Pulse Animation and Smart Loading States