Skip to content
Codeloom
React

React Concurrent Features: useTransition, useDeferredValue, and More

Master React's concurrent rendering features including useTransition, useDeferredValue, and strategies for keeping your UI responsive.

·6 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • What concurrent rendering means in React
  • Using useTransition to keep the UI responsive
  • Deferring expensive updates with useDeferredValue
  • Combining concurrent features with Suspense
  • Practical patterns and when to use each

Prerequisites

  • Solid React fundamentals
  • Familiarity with useState and useEffect

Concurrent rendering lets React work on multiple versions of the UI at the same time. Instead of blocking the main thread while rendering a large update, React can pause, work on something more urgent (like a keystroke), and then resume. The hooks useTransition and useDeferredValue give you control over which updates are urgent and which can wait.

The Problem: Blocking Updates

Imagine a search input that filters a list of 10,000 items. Without concurrent features, every keystroke triggers a full re-render of the list. The input feels sluggish because React cannot paint the new character until the entire list finishes rendering.

function SearchPage() {
  const [query, setQuery] = useState('');
  const filteredItems = items.filter(item =>
    item.name.toLowerCase().includes(query.toLowerCase())
  );

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <ItemList items={filteredItems} />
    </div>
  );
}

Every change to query is treated as equally urgent. The user’s typing and the list filtering compete for the same rendering cycle.

useTransition: Marking Updates as Non-Urgent

useTransition lets you mark a state update as a transition, meaning React can interrupt it if something more urgent comes in.

import { useState, useTransition } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const [filteredItems, setFilteredItems] = useState(items);
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value); // Urgent: update the input immediately

    startTransition(() => {
      // Non-urgent: filter the list in the background
      const filtered = items.filter(item =>
        item.name.toLowerCase().includes(value.toLowerCase())
      );
      setFilteredItems(filtered);
    });
  };

  return (
    <div>
      <input
        value={query}
        onChange={handleChange}
        placeholder="Search..."
      />
      {isPending && <p className="loading">Filtering...</p>}
      <ItemList items={filteredItems} />
    </div>
  );
}

The setQuery update is urgent and renders immediately, keeping the input responsive. The setFilteredItems update is wrapped in startTransition, so React can defer it. If the user types another character before the list finishes rendering, React abandons the stale render and starts a new one.

The isPending flag is true while the transition is in progress, so you can show a subtle loading indicator without blocking the input.

useDeferredValue: Deferring a Derived Value

useDeferredValue is the declarative cousin of useTransition. Instead of wrapping a state setter, you wrap a value. React keeps showing the old value until it finishes rendering with the new one.

import { useState, useDeferredValue } from 'react';

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

  const filteredItems = items.filter(item =>
    item.name.toLowerCase().includes(deferredQuery.toLowerCase())
  );

  const isStale = query !== deferredQuery;

  return (
    <div>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <div style={{ opacity: isStale ? 0.7 : 1 }}>
        <ItemList items={filteredItems} />
      </div>
    </div>
  );
}

The input updates instantly because query changes immediately. The list uses deferredQuery, which lags behind. React renders the list with the deferred value in the background. While the deferred render is in progress, the list fades slightly to indicate stale data.

When to Use Which

Use useTransition when you control the state update and want to split it into urgent and non-urgent parts. Use useDeferredValue when you receive a value from a parent (via props or context) and want to defer the expensive computation that depends on it.

ScenarioUse
You own the state setteruseTransition
Value comes from a propuseDeferredValue
Wrapping a navigationuseTransition
Deferring a child renderuseDeferredValue

useTransition with Navigation

Transitions work well for page navigations. Wrap the route change in startTransition so the old page stays visible while the new page loads, instead of showing a blank screen.

import { useTransition } from 'react';
import { useRouter } from 'next/navigation';

function NavLink({ href, children }) {
  const router = useRouter();
  const [isPending, startTransition] = useTransition();

  const handleClick = (e) => {
    e.preventDefault();
    startTransition(() => {
      router.push(href);
    });
  };

  return (
    <a
      href={href}
      onClick={handleClick}
      style={{ opacity: isPending ? 0.6 : 1 }}
    >
      {children}
    </a>
  );
}

The link dims while the new page is loading, but the current page remains interactive.

Combining with Suspense

Concurrent features and Suspense are designed to work together. When a transition triggers a component to suspend (because it reads from a promise), React keeps showing the current UI instead of the Suspense fallback.

function App() {
  const [tab, setTab] = useState('home');
  const [isPending, startTransition] = useTransition();

  const switchTab = (newTab) => {
    startTransition(() => {
      setTab(newTab);
    });
  };

  return (
    <div>
      <nav>
        <button onClick={() => switchTab('home')}>Home</button>
        <button onClick={() => switchTab('posts')}>Posts</button>
        <button onClick={() => switchTab('settings')}>Settings</button>
      </nav>
      <div style={{ opacity: isPending ? 0.7 : 1 }}>
        <Suspense fallback={<Spinner />}>
          {tab === 'home' && <Home />}
          {tab === 'posts' && <Posts />}
          {tab === 'settings' && <Settings />}
        </Suspense>
      </div>
    </div>
  );
}

Without startTransition, switching to Posts would immediately show the spinner. With the transition, React keeps showing the current tab while Posts loads in the background.

Avoiding Common Mistakes

Do not wrap urgent updates in transitions. Typing in an input, clicking a toggle, or opening a dropdown should always be urgent. Only defer the expensive downstream effects.

// Wrong: the input will feel laggy
startTransition(() => {
  setQuery(e.target.value);
});

// Right: update input urgently, defer the filter
setQuery(e.target.value);
startTransition(() => {
  setFilteredResults(filter(e.target.value));
});

Do not over-defer. If the computation is fast (filtering 50 items), using useDeferredValue adds complexity without benefit. These hooks help when the downstream render is expensive, typically hundreds or thousands of DOM nodes.

Check isPending placement. The pending indicator should be subtle: a faded list, a small spinner, or a progress bar. Avoid replacing content with a full-page loader, which defeats the purpose of keeping the old UI visible.

Performance Gains in Practice

Concurrent features do not make your code run faster. They make your UI feel faster by prioritizing what the user sees immediately. The total work is the same, but the input never freezes because React yields control back to the browser between rendering chunks. For CPU-intensive updates like filtering large datasets, rendering charts, or switching complex tabs, the improvement in perceived responsiveness is significant.