Skip to main content
  1. Frontends/
  2. React Guides/

Mastering Concurrent Rendering: A Deep Dive into Transitions and Deferring

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

The era of “janky” user interfaces is officially over. In the landscape of 2025, users—and their high-refresh-rate displays—have zero tolerance for blocked main threads. If your dashboard stutters when a user types into a filter input, you aren’t just losing frames; you’re losing trust.

For years, React developers relied on setTimeout, debounce, and throttle to hack around expensive rendering tasks. While those techniques still have their place, React’s Concurrent Architecture has matured into the standard way to handle heavy UI updates without sacrificing responsiveness.

This isn’t just about making things faster; it’s about perceived performance and interruptibility.

In this deep dive, we are going to tear down the mechanics of useTransition and useDeferredValue. We won’t just look at the API; we’ll engineer a real-world scenario where the main thread chokes, and then we’ll surgically repair it using concurrent features.

The Paradigm Shift: Rendering is no longer atomic
#

Before we touch the code, we need to align our mental models. In the “old world” (React 17 and prior), once rendering started, it couldn’t be stopped. It was an atomic operation. If you had a list of 10,000 items to render based on a search query, React would block the browser from doing anything else—including typing—until that list was finished.

Concurrent React changes the rules of physics here. It allows React to:

  1. Pause a render in the middle.
  2. Abandon a stale render if new data comes in.
  3. Prioritize urgent updates (typing, clicking) over non-urgent ones (filtering a chart).

This is achieved through Time Slicing. React breaks the rendering work into small chunks, constantly checking if the browser needs to handle a user interaction.

Prerequisites & Environment
#

To follow along, ensure you have a modern environment set up. We assume you are comfortable with TypeScript and Hooks.

  • Node.js: v20+ (LTS recommended)
  • React: v18.3 or v19 (The concepts apply to both, but v19 stabilizes Suspense data fetching integrations).
  • Scaffolding: Vite (Create React App is legacy; do not use it).

Setup Command:

npm create vite@latest concurrent-demo -- --template react-ts
cd concurrent-demo
npm install

The “Heavy” Problem
#

Let’s simulate a common performance bottleneck: A text input that filters a massive dataset. To make the problem obvious without downloading megabytes of data, we will artificially slow down the rendering of list items.

Create a file named HeavyList.tsx.

// src/components/HeavyList.tsx
import React, { memo } from 'react';

// Artificially slow component
const SlowItem = ({ text }: { text: string }) => {
  const startTime = performance.now();
  // Block the thread for 1ms per item intentionally
  while (performance.now() - startTime < 1) {
    // Do nothing, just burn CPU cycles
  }
  return <li className="p-2 border-b border-gray-100">{text}</li>;
};

// Memoized to ensure re-renders are triggered by prop changes
export const HeavyList = memo(({ query }: { query: string }) => {
  const items = [];
  
  // Generate 500 items. With 1ms delay each, this component takes ~500ms to render.
  // This is disastrous for the main thread.
  for (let i = 0; i < 500; i++) {
    if (query === '' || `Item ${i}`.toLowerCase().includes(query.toLowerCase())) {
      items.push(<SlowItem key={i} text={`Item ${i} (Match: ${query})`} />);
    }
  }

  return <ul className="mt-4 border rounded shadow-sm">{items}</ul>;
});

Now, let’s implement the Naive Solution in App.tsx. This represents how most legacy React apps are written.

// src/App.tsx (The "Janky" Version)
import React, { useState } from 'react';
import { HeavyList } from './components/HeavyList';

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

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // 🛑 This update is treated as URGENT.
    // It triggers the HeavyList render immediately, blocking the input.
    setQuery(e.target.value); 
  };

  return (
    <div className="max-w-2xl mx-auto p-8 font-sans">
      <h1 className="text-3xl font-bold mb-6 text-slate-800">Legacy Rendering</h1>
      <input
        type="text"
        value={query}
        onChange={handleChange}
        placeholder="Type quickly here..."
        className="w-full p-3 border-2 border-slate-300 rounded-lg focus:border-blue-500 outline-none transition"
      />
      <HeavyList query={query} />
    </div>
  );
}

export default App;

The Result: If you type “React” quickly into that box, the input field will freeze. You might type “R”, wait 0.5s, see “R”, type “e”, wait… It feels broken. This happens because the browser is trying to paint the input letter and the 500 items in the same frame.


Strategy 1: useTransition
#

useTransition is your primary tool for telling React: “Update the state, but this part isn’t urgent. If the user does something else, interrupt this.”

It splits state updates into two categories:

  1. Urgent updates: Direct interactions like typing, clicking, pressing.
  2. Transition updates: UI views, list filtering, screen navigation.

Implementation
#

We need to decouple the input value (what the user sees) from the list query (what the heavy component uses).

// src/components/TransitionExample.tsx
import React, { useState, useTransition } from 'react';
import { HeavyList } from './HeavyList';

export const TransitionExample = () => {
  const [inputVal, setInputVal] = useState('');
  const [query, setQuery] = useState('');
  
  // hook returns [isPending, startTransition]
  const [isPending, startTransition] = useTransition();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const newValue = e.target.value;
    
    // 1. URGENT: Update the input field immediately
    // The user sees what they type instantly.
    setInputVal(newValue);

    // 2. TRANSITION: Schedule the heavy update
    // React creates a branch, calculates the new tree in the background.
    // If the user types again, this work is thrown away.
    startTransition(() => {
      setQuery(newValue);
    });
  };

  return (
    <div className="max-w-2xl mx-auto p-8 font-sans">
      <h2 className="text-2xl font-bold mb-4 text-slate-800">
        Optimization: useTransition
      </h2>
      
      <div className="relative">
        <input
          type="text"
          value={inputVal}
          onChange={handleChange}
          className="w-full p-3 border-2 border-slate-300 rounded-lg focus:border-green-500 outline-none"
        />
        {/* UX Enhancement: Show a spinner while the transition is calculating */}
        {isPending && (
          <div className="absolute right-3 top-3 text-sm text-gray-400 animate-pulse">
            Rendering...
          </div>
        )}
      </div>

      {/* The heavy list uses the deferred 'query' state */}
      <div style={{ opacity: isPending ? 0.5 : 1, transition: 'opacity 0.2s' }}>
        <HeavyList query={query} />
      </div>
    </div>
  );
};

How It Works Under the Hood
#

When startTransition is invoked, React flags the setQuery update as low priority. React yields back to the browser’s main thread frequently. If you type ‘a’, React starts rendering the list for ‘a’. If you type ‘b’ before ‘a’ is finished, React abandons the ‘a’ work and starts rendering ‘ab’.

This keeps the input responsive 100% of the time.


Strategy 2: useDeferredValue
#

Sometimes, you don’t have access to the set function (e.g., the value comes from props or a custom hook), or you want to defer a value rather than an action.

useDeferredValue is similar to debouncing, but better. Debouncing waits for a fixed time (e.g., 300ms) before starting work. useDeferredValue starts work immediately but works in the background, ready to be interrupted.

Implementation
#

Let’s refactor the previous example to use useDeferredValue. This is often cleaner as it doesn’t require maintaining two separate state variables manually.

// src/components/DeferringExample.tsx
import React, { useState, useDeferredValue } from 'react';
import { HeavyList } from './HeavyList';

export const DeferringExample = () => {
  const [query, setQuery] = useState('');
  
  // React keeps the "old" value until the "new" value is fully rendered in the background.
  const deferredQuery = useDeferredValue(query);

  // Check if the deferred value is stale to show a loading state
  const isStale = query !== deferredQuery;

  return (
    <div className="max-w-2xl mx-auto p-8 font-sans">
      <h2 className="text-2xl font-bold mb-4 text-slate-800">
        Optimization: useDeferredValue
      </h2>
      
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)} // Urgent update
        className="w-full p-3 border-2 border-slate-300 rounded-lg focus:border-purple-500 outline-none"
      />

      <div className={`mt-4 transition-opacity ${isStale ? 'opacity-50' : 'opacity-100'}`}>
        {/* We pass the DEFERRED value to the heavy component */}
        <HeavyList query={deferredQuery} />
      </div>
    </div>
  );
};

Visualizing the Concurrency Flow
#

To understand the difference between standard synchronous rendering and concurrent features, look at the timeline below. Notice how concurrent mode allows user input to “cut in line.”

sequenceDiagram participant U as User participant R as React (Main Thread) participant DOM as Browser Paint note over U, DOM: Standard Synchronous Rendering (Blocking) U->>R: Types "A" R->>R: Render Input "A" R->>R: Render HeavyList "A" (500ms) Note right of R: Thread Blocked 🛑 U->>R: Types "B" (Ignored/Queued) R->>DOM: Commit & Paint "A" R->>R: Render Input "B"... note over U, DOM: Concurrent Rendering (Interruptible) U->>R: Types "A" R->>DOM: Paint Input "A" (Urgent) R->>R: Start Transition "A" (Background) R->>R: Work Loop... U->>R: Types "B" (Interrupts!) ⚡ R->>R: Abandon Transition "A" 🗑️ R->>DOM: Paint Input "AB" (Urgent) R->>R: Start Transition "AB" (Background) R->>DOM: Commit HeavyList "AB"

Comparative Analysis: Picking the Right Tool
#

React developers often confuse useTransition with useDeferredValue, or wonder why they shouldn’t just use lodash.debounce. Here is the breakdown.

Feature Primary Use Case Control Level Rendering Behavior
useTransition Wrapping state setters (actions). Useful when you trigger the change (e.g., onClick, onChange). High (provides isPending). Updates state immediately, but defers the effect of that update on the DOM.
useDeferredValue Wrapping values from props or hooks. Useful when you receive data you didn’t set. Medium (check val !== deferredVal). Keeps using the old value until the new one is calculated in the background.
Debounce API calls or very expensive calculations that shouldn’t happen often. Low (Timer based). delays the start of the update. Blocks thread once it starts.
Throttle Scroll events, resize handlers. Low (Frequency based). Limits execution rate. Still blocks thread during execution.

Why not just use Debounce?
#

Debouncing feels sluggish. If you set a debounce of 500ms, the user always waits 500ms, even if their device is a fast MacBook Pro that could have rendered the list in 50ms.

Concurrent features are adaptive.

  • Fast Device: React finishes the transition almost instantly. Zero lag.
  • Slow Device: React yields to the main thread, keeping the UI responsive, but taking longer to show the list.

Performance Traps and Best Practices
#

As with any advanced API, it is easy to shoot yourself in the foot. Here are the heuristics I use in production applications.

1. Don’t Wrap Inputs
#

Never wrap the state controlling the input field itself in a transition.

// ❌ BAD: Typing will feel laggy
const handleChange = (e) => {
  startTransition(() => {
    setInputValue(e.target.value); // This needs to be urgent!
  });
};

2. Handle “Pending” States Gracefully
#

When suspending or deferring, the UI might show stale data. This can be confusing.

  • With useTransition, use isPending to show a loading spinner or disable the button.
  • With useDeferredValue, use the opacity trick (shown in the code above) to indicate that the displayed data is slightly out of sync with the input.

3. Integration with Suspense
#

In React 19 (and 18), these features integrate deeply with <Suspense>. If a transition causes a component to suspend (e.g., waiting for data), React won’t replace the current UI with a fallback skeleton immediately. instead, it will hold the “old” UI visible (pending state) until the new data is ready. This prevents “layout shift” and “loading indicator flashing.”

4. CPU Throttling for Testing
#

You cannot test these features effectively on a high-end development machine.

  1. Open Chrome DevTools.
  2. Go to the Performance tab.
  3. Set CPU throttling to 4x slowdown or 6x slowdown.
  4. Interact with your UI. This is where Concurrency shines.

Conclusion
#

Mastering React’s Concurrent features—useTransition and useDeferredValue—is what separates a Junior React developer from a Senior Architect. It moves us away from arbitrary time delays (setTimeout) and toward a declarative priority system that respects the user’s hardware capabilities.

By implementing these patterns, you ensure your application scales not just in code size, but in complexity. You can render thousands of rows, complex charts, or 3D visualizations while keeping that text cursor blinking smoothly at 60fps.

Next Steps for the curious:

  • Explore Server Components (RSC) and how streaming integrates with transitions.
  • Look into useOptimistic (React 19) for handling form mutations concurrently.

The web is single-threaded. But with React Concurrency, your users will never feel like it is.


Further Reading
#