jsmanifest logojsmanifest

useTransition and useDeferredValue: React Performance Hooks Explained

useTransition and useDeferredValue: React Performance Hooks Explained

Learn how React's useTransition and useDeferredValue hooks solve performance bottlenecks through concurrent rendering. Discover when to use each hook with practical examples.

While I was looking over some React performance issues the other day, I came across a frustrating problem I'm sure many of you have experienced: typing in a search box that feels sluggish because it's filtering thousands of items simultaneously. The input lags, the UI stutters, and your users start questioning if their browser is frozen.

I was once guilty of throwing a debounce at this problem and calling it a day. Little did I know that React 18 introduced two hooks specifically designed to solve this—useTransition and useDeferredValue. But here's the catch: most developers (including myself initially) don't understand when to use which one.

Understanding React's Concurrent Rendering Problem

Before React 18, all state updates were treated equally. When you typed in a search box that filtered a massive list, React would block the entire UI until both the input update and the expensive filtering completed. This meant your keypresses felt laggy because React was busy recalculating those 10,000 filtered results.

React's concurrent rendering changes this game entirely. It allows React to work on multiple versions of the UI simultaneously and interrupt non-urgent work. Think of it like this: if a user is typing, updating the input field is urgent (users expect instant feedback), but recalculating filtered results can wait a few milliseconds.

The problem is that React can't automatically know which updates are urgent and which aren't. That's where these two hooks come in. They give us the ability to tell React, "Hey, this particular state update isn't as critical—work on it when you have time."

useTransition: Prioritizing State Updates

The useTransition hook lets you mark specific state updates as non-urgent transitions. When I finally decided to actually read the React documentation instead of skimming it, I realized this hook is perfect for when you control the state update.

Here's what it returns:

const [isPending, startTransition] = useTransition();

The isPending boolean tells you if the transition is still in progress, and startTransition is a function you wrap around non-urgent state updates. React will prioritize other updates (like input changes) while working on your transition in the background.

React concurrent rendering visualization

Building a Search Filter with useTransition

Let me show you a practical example. I came across this pattern while building a product filter for an e-commerce dashboard:

import { useState, useTransition } from 'react';
 
interface Product {
  id: string;
  name: string;
  category: string;
  price: number;
}
 
function ProductSearch({ products }: { products: Product[] }) {
  const [searchTerm, setSearchTerm] = useState('');
  const [filteredProducts, setFilteredProducts] = useState(products);
  const [isPending, startTransition] = useTransition();
 
  const handleSearch = (value: string) => {
    // This update is urgent - user expects instant feedback
    setSearchTerm(value);
 
    // This update can be deferred - filtering is expensive
    startTransition(() => {
      const filtered = products.filter(product =>
        product.name.toLowerCase().includes(value.toLowerCase()) ||
        product.category.toLowerCase().includes(value.toLowerCase())
      );
      setFilteredProducts(filtered);
    });
  };
 
  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="Search products..."
      />
      {isPending && <div className="loading-indicator">Filtering...</div>}
      <div className="product-list">
        {filteredProducts.map(product => (
          <div key={product.id} className="product-card">
            <h3>{product.name}</h3>
            <p>{product.category} - ${product.price}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

Notice how we're calling setSearchTerm immediately, but wrapping setFilteredProducts in startTransition. This tells React: "Update the input right away (users need to see what they typed), but feel free to delay the filtering if something more urgent comes up."

Luckily we can also use the isPending flag to show a subtle loading indicator. This gives users feedback that work is happening without blocking their interaction.

useDeferredValue: Deferring Non-Critical Updates

While useTransition is wonderful for when you control the state update, useDeferredValue solves a different problem: what if you're receiving a value as a prop or from a hook you don't control?

I cannot stress this enough—useDeferredValue doesn't wrap a state setter. Instead, it wraps the value itself:

const deferredValue = useDeferredValue(value);

React will initially return the current value, but during concurrent rendering, it may return a "stale" version of the value to keep the UI responsive. Once React has spare time, it updates the deferred value to match the latest one.

In other words, while useTransition lets you mark actions as low priority, useDeferredValue marks data as low priority.

useTransition vs useDeferredValue: When to Use Each

Here's how I think about choosing between them:

Use useTransition when:

  • You control the state update function
  • You want to show loading states during transitions
  • You're handling user actions (button clicks, form submissions)
  • You need fine-grained control over what's urgent vs non-urgent

Use useDeferredValue when:

  • You receive a value from props or another hook
  • You don't control the state update mechanism
  • You're dealing with derived or computed values
  • You want a simpler API without manually wrapping updates

Let me show you a side-by-side comparison. Here's the same search functionality using useDeferredValue:

import { useState, useDeferredValue, useMemo } from 'react';
 
function ProductSearchWithDeferred({ products }: { products: Product[] }) {
  const [searchTerm, setSearchTerm] = useState('');
  const deferredSearchTerm = useDeferredValue(searchTerm);
 
  // This expensive calculation uses the deferred value
  const filteredProducts = useMemo(() => {
    return products.filter(product =>
      product.name.toLowerCase().includes(deferredSearchTerm.toLowerCase()) ||
      product.category.toLowerCase().includes(deferredSearchTerm.toLowerCase())
    );
  }, [products, deferredSearchTerm]);
 
  // Check if we're showing stale data
  const isStale = searchTerm !== deferredSearchTerm;
 
  return (
    <div>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search products..."
      />
      <div className="product-list" style={{ opacity: isStale ? 0.6 : 1 }}>
        {filteredProducts.map(product => (
          <div key={product.id} className="product-card">
            <h3>{product.name}</h3>
            <p>{product.category} - ${product.price}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

See the difference? With useDeferredValue, we're not wrapping any state setters. We're simply saying "use a potentially stale version of searchTerm for the expensive filtering operation." The input updates immediately with the real searchTerm, while the filtering uses deferredSearchTerm which may lag behind.

Comparison diagram of useTransition and useDeferredValue

Real-World Pattern: Tab Switching with Transitions

When I was building a dashboard with multiple data-heavy tabs, I discovered a fascinating use case for useTransition. Users would click a tab and the entire UI would freeze while loading the new tab's content. This felt terrible.

Here's the pattern I use now:

function Dashboard() {
  const [activeTab, setActiveTab] = useState('overview');
  const [isPending, startTransition] = useTransition();
 
  const handleTabChange = (tab: string) => {
    startTransition(() => {
      setActiveTab(tab);
    });
  };
 
  return (
    <div>
      <nav className="tabs">
        <button
          onClick={() => handleTabChange('overview')}
          disabled={isPending}
        >
          Overview
        </button>
        <button
          onClick={() => handleTabChange('analytics')}
          disabled={isPending}
        >
          Analytics {isPending && '⏳'}
        </button>
        <button
          onClick={() => handleTabChange('reports')}
          disabled={isPending}
        >
          Reports
        </button>
      </nav>
      
      {/* Previous tab stays visible during transition */}
      <TabContent tab={activeTab} />
    </div>
  );
}

The beauty here is that React keeps the old tab visible while preparing the new one. Users can still interact with the current tab, and when the new tab is ready, React swaps them instantly. It's a much better experience than showing a loading spinner or freezing the UI.

Common Mistakes and Performance Pitfalls

While working with these hooks, I've made my share of mistakes. Let me save you some debugging time:

Mistake #1: Wrapping everything in transitions. I once wrapped every single state update in startTransition, thinking it would magically make my app faster. It didn't. Transitions add overhead, so only use them for genuinely expensive updates. Your counter button doesn't need a transition.

Mistake #2: Forgetting to memoize with useDeferredValue. If you're using useDeferredValue but not wrapping your expensive calculation in useMemo, you're not getting any benefit. React needs to know what computation depends on the deferred value.

Mistake #3: Using transitions for critical updates. I cannot stress this enough: don't wrap state updates that must happen immediately. Form validation, error messages, or navigation states should never be in a transition—users expect instant feedback.

Mistake #4: Overthinking the choice. When in doubt, start with useDeferredValue—it's simpler and covers most use cases. Only reach for useTransition when you need that isPending flag or want more control over exactly which updates are deferred.

Choosing the Right Hook for Your Use Case

After using both hooks in production for several months, here's my mental model: useTransition is for actions, useDeferredValue is for values.

If you're responding to a user action and want to show loading states, use useTransition. If you're deriving expensive data from a value and want to prevent UI lag, use useDeferredValue.

Both hooks are about the same thing fundamentally—telling React which updates can wait. The syntax just differs based on whether you control the update mechanism or are working with values you receive.

One more thing: these hooks don't replace code splitting, virtualization, or proper memoization. They're part of your performance toolkit, not the entire toolkit. I still use React.memo, useMemo, and useCallback alongside these hooks for the best results.

And that concludes the end of this post! I hope you found this valuable and look out for more in the future!