| |

React 50 ⚛️ Non-Blocking Rendering with useTransition

⏱️ Estimated read: 22 min | |

A React application has two kinds of updates. The first is the urgent update: the user types a character, clicks a button, presses a key. The response must be immediate, or the interface feels broken. The second is the non-urgent update: the search results filter, the chart recalculates, the tab content loads. The response can wait a few milliseconds, and the user should not be blocked while it happens. Before React 18, every update was treated as urgent, and a heavy computation blocked the main thread until it finished. The input lagged, the clicks queued, and the interface froze. useTransition is the hook that marks an update as non-urgent, allowing React to interrupt it when the urgent work arrives .

The mechanism is the transition. When a state update is wrapped in the startTransition function, React assigns it to a lower-priority lane. The scheduler can pause, resume, or abandon the work. If the user types another character while the transition is in progress, React interrupts the transition, processes the new input, and restarts the transition with the updated value . The input stays responsive because the urgent update jumps the queue. The transition completes in the background, and the results appear when the work is done. The isPending boolean, returned alongside the startTransition function, indicates whether a transition is in progress, so the UI can show a subtle loading indicator without hiding the existing content .

The distinction between useTransition and useDeferredValue is the distinction between controlling the update and deferring the value. useTransition is used when you have access to the state setter and want to mark the update as non-urgent. useDeferredValue is used when the value comes from props or an external source, and you want to create a deferred version that lags behind the current value . The two hooks work together with React.memo: the memo prevents the child from re-rendering when the props are unchanged, and the transition or the deferred value keeps the input responsive while the expensive work happens in the background .

This chapter covers three areas. First, why non-blocking rendering exists — the urgent-versus-non-urgent problem and the interruptible render. Second, how useTransition works — the startTransition function, the isPending boolean, the double render, and the lane scheduling. Third, the patterns and the pitfalls — the search-as-you-type, the tab navigation, the router integration, the difference from debouncing, and the cases where transitions are the wrong tool. The chapter ends with a complete example session, a quick reference, best practices, common pitfalls, real-world examples, and diagrams showing the transition scheduling.

Key point: useTransition marks a state update as non-urgent and interruptible. It returns the isPending boolean and the startTransition function. The urgent updates run immediately, and the transition runs in the background, allowing React to interrupt it when the user interacts again. Use it for the expensive CPU-bound state updates, not for the network requests. Keep the controlled input state outside the transition, and wrap only the derived or filtered state.


Why non-blocking rendering exists

The blocking-render problem. Before React 18, the rendering was synchronous and uninterruptible. Once React started rendering a component tree, it ran to completion, and the main thread was blocked. A heavy computation — filtering a list of ten thousand items, recalculating a chart, formatting a large table — froze the interface for the duration. The user typed, and the characters appeared with a delay. The user clicked, and the response came seconds later. The interface felt broken, even though the computation was correct .

The urgent-versus-non-urgent problem. Not every update is equally important. The keystroke that updates the input value must be immediate. The filtering that updates the results list can wait. The tab highlight that shows the active tab must be immediate. The content loading for the new tab can wait. React’s concurrent scheduler introduces the priority: the urgent updates are processed first, and the non-urgent updates are processed when the scheduler has time. The transition is the mechanism that marks an update as non-urgent .

The interruptibility problem. The key property of a transition is the interruptibility. When a transition is in progress and a new urgent update arrives, React abandons the in-progress transition and processes the urgent update first. The transition restarts with the new state. The user never waits for the stale computation, and the input stays responsive . The interruptibility is what separates the transition from the debounce: the debounce delays the work with a fixed timer, and the transition starts the work immediately but allows it to be interrupted .

The loading-indicator problem. A transition can take time, and the user should know that the work is in progress. The isPending boolean is the indicator: it is true while the transition is running and false when it completes. The UI can show a subtle spinner, dim the stale content, or display the “Updating…” text, without replacing the whole view with a loading screen. The pending indicator is the gentle feedback, and it is the improvement over the jarring loading fallback .

The memoization problem. The transition alone does not make the child components faster. If the parent re-renders and the child re-renders with it, the transition’s benefit is lost. The React.memo is the companion: the child is memoized, and it re-renders only when its props change. The transition keeps the input responsive, and the memo keeps the expensive child from re-rendering unnecessarily. The two work together, and the React documentation recommends the memoization for the components that use the transitions .

The trade-off. The transition adds the complexity: the double render, the pending state, the lane scheduling. For the cheap updates, the transition is the overhead without the benefit. The setCount((c) => c + 1) is already fast, and wrapping it in the transition adds the unnecessary work. The transition is for the genuinely expensive CPU-bound updates, not for the trivial state changes .


a. The basic useTransition

The useTransition hook returns an array with two elements: the isPending boolean and the startTransition function.

import { useState, useTransition } from 'react';

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (value) => {
    setQuery(value); // Urgent: update the input immediately

    startTransition(() => {
      setResults(expensiveSearch(value)); // Non-urgent: defer the filtering
    });
  };

  return (
    <>
      <input value={query} onChange={(e) => handleSearch(e.target.value)} />
      {isPending && <div>Searching...</div>}
      <ResultsList results={results} />
    </>
  );
}

The setQuery(value) runs synchronously, and the input updates immediately. The setResults inside the startTransition is marked as non-urgent, and the filtering happens in the background. The isPending is true while the filtering is in progress, and the “Searching…” indicator appears. If the user types another character, the in-progress filtering is interrupted, and the new input is processed first .

The standalone startTransition. The startTransition function can be imported directly, without the hook. It is used when the isPending is not needed, or when the transition is started outside the component.

import { startTransition } from 'react';

function updateGlobalState(newValue) {
  startTransition(() => {
    globalState.value = newValue;
  });
}

The standalone form is for the cases where the hook is not available, such as the module-level code or the event handlers that do not need the pending state .


b. The isPending and the double render

The isPending boolean requires the double render. When the startTransition is called, React performs two renders: the first is the immediate, high-priority render where the isPending becomes true, and the second is the deferred, low-priority render where the transition’s state updates and the isPending becomes false .

The first render shows the pending state: the spinner, the dimmed content, the “Updating…” text. The second render shows the final state: the new results, the completed chart, the loaded content. The double render is the mechanism that allows the UI to indicate the pending state without blocking the input.

The lane scheduling. React 18 introduced the lanes for the scheduling. The urgent updates are assigned to the high-priority lanes, and the transitions are assigned to the low-priority lanes. The scheduler processes the high-priority lanes first, and the low-priority lanes when the high-priority lanes are empty. The transition is the low-priority lane, and it is the reason the transition is interruptible .

The batching. Multiple state updates inside the same startTransition are batched together as a single transition. The setResults and the setPage and the setSelected are one transition, and the isPending is true until the whole batch is rendered .


c. The patterns and the pitfalls

The search-as-you-type pattern. The classic use case is the search. The input updates immediately, and the filtering is deferred.

function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    setQuery(e.target.value); // Urgent

    startTransition(() => {
      setResults(filterItems(e.target.value)); // Non-urgent
    });
  };

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <Spinner />}
      <ResultsList results={results} />
    </>
  );
}

The input stays responsive, and the results update in the background. The isPending shows the subtle spinner .

The tab navigation pattern. The tab highlight updates immediately, and the tab content loads as a transition.

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

  const selectTab = (nextTab) => {
    startTransition(() => {
      setActiveTab(nextTab); // The transition
    });
  };

  return (
    <>
      <TabButton isActive={activeTab === 'home'} onClick={() => selectTab('home')}>
        Home
      </TabButton>
      <TabButton isActive={activeTab === 'posts'} onClick={() => selectTab('posts')}>
        Posts {isPending && '(Loading...)'}
      </TabButton>
      <div>{activeTab === 'home' ? <HomeTab /> : <PostsTab />}</div>
    </>
  );
}

The tab highlight is inside the transition, so the click does not block, and the “Loading…” appears in the tab button. The transition prevents the jarring replacement of the whole tab container with the loading fallback .

The router navigation pattern. The router marks the page navigations as transitions, so the user can click away without waiting for the re-render.

function navigate(url) {
  startTransition(() => {
    setPage(url);
  });
}

The transition is recommended for the routers because it is interruptible and it prevents the unwanted loading indicators .

The transition versus the debounce. The debounce delays the work with a fixed timer, and the transition starts the work immediately but allows it to be interrupted. The debounce is the right choice for the network requests, where the excessive requests are the problem. The transition is the right choice for the CPU-bound work, where the blocking is the problem .

The controlled input pitfall. The transition cannot be used for the state that controls the input. The setText inside the startTransition causes the input to lag behind the keystrokes, and the interface feels broken. The fix is to keep the input state outside the transition, and to wrap only the derived state:

// ❌ The input state inside the transition
startTransition(() => {
  setText(e.target.value);
});

// ✅ The input state outside, the derived state inside
setText(e.target.value);
startTransition(() => {
  setFilteredText(e.target.value);
});

The two state variables: the text for the input, and the filteredText for the expensive rendering. The text updates synchronously, and the filteredText updates in the transition .

The network-request pitfall. The transition does not wait for the promises. If the fetch is inside the transition, the transition completes immediately, and the isPending becomes false while the request is still in flight. The transition is for the CPU-bound work, not for the I/O. The Suspense is the companion for the data fetching, and the transition works with the Suspense to keep the existing content visible while the new data loads .

The cheap-update pitfall. The transition is the overhead for the cheap updates. The setCount is already fast, and the transition adds the double render without the benefit. The transition is for the expensive CPU-bound work, and the simple state changes are the regular updates .


Complete Example Session

// ============================================
// PART 1: THE BASIC TRANSITION
// ============================================

import { useState, useTransition } from 'react';

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (value) => {
    setQuery(value); // Urgent: update the input immediately

    startTransition(() => {
      setResults(expensiveSearch(value)); // Non-urgent: defer the filtering
    });
  };

  return (
    <>
      <input value={query} onChange={(e) => handleSearch(e.target.value)} />
      {isPending && <div>Searching...</div>}
      <ResultsList results={results} />
    </>
  );
}

// The input stays responsive, and the filtering happens in the background.


// ============================================
// PART 2: THE isPending INDICATOR
// ============================================

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (value) => {
    setQuery(value);
    startTransition(() => setResults(expensiveSearch(value)));
  };

  return (
    <>
      <input value={query} onChange={(e) => handleSearch(e.target.value)} />
      <div style={{ opacity: isPending ? 0.5 : 1 }}>
        <ResultsList results={results} />
      </div>
      {isPending && <Spinner />}
    </>
  );
}

// The results dim while the transition is in progress.


// ============================================
// PART 3: THE STANDALONE startTransition
// ============================================

import { startTransition } from 'react';

function TabContainer() {
  const [activeTab, setActiveTab] = useState('home');

  const selectTab = (nextTab) => {
    setActiveTab(nextTab); // Urgent: update the highlight immediately
  };

  const loadContent = (tab) => {
    startTransition(() => {
      setContent(getContentForTab(tab)); // Non-urgent: load the content
    });
  };

  return (
    <>
      {tabs.map((tab) => (
        <button
          key={tab}
          onClick={() => {
            selectTab(tab);
            loadContent(tab);
          }}
        >
          {tab}
        </button>
      ))}
      <div>{content}</div>
    </>
  );
}

// The highlight updates immediately, and the content loads as a transition.


// ============================================
// PART 4: THE TAB NAVIGATION
// ============================================

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

  const selectTab = (nextTab) => {
    startTransition(() => {
      setActiveTab(nextTab);
    });
  };

  return (
    <>
      <TabButton isActive={activeTab === 'home'} onClick={() => selectTab('home')}>
        Home
      </TabButton>
      <TabButton isActive={activeTab === 'posts'} onClick={() => selectTab('posts')}>
        Posts {isPending && '(Loading...)'}
      </TabButton>
      <div>
        {activeTab === 'home' && <HomeTab />}
        {activeTab === 'posts' && <PostsTab />}
      </div>
    </>
  );
}

// The "Loading..." appears in the tab button, not in the whole container.


// ============================================
// PART 5: THE CONTROLLED INPUT PITFALL
// ============================================

// ❌ The input state inside the transition
function BrokenSearch() {
  const [text, setText] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    startTransition(() => {
      setText(e.target.value); // The input lags behind the keystrokes
    });
  };

  return <input value={text} onChange={handleChange} />;
}

// ✅ The input state outside, the derived state inside
function WorkingSearch() {
  const [text, setText] = useState('');
  const [filteredText, setFilteredText] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    setText(e.target.value); // Urgent: update the input immediately
    startTransition(() => {
      setFilteredText(e.target.value); // Non-urgent: defer the filtering
    });
  };

  return (
    <>
      <input value={text} onChange={handleChange} />
      <Results query={filteredText} />
    </>
  );
}


// ============================================
// PART 6: THE TRANSITION AND THE MEMO
// ============================================

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

const ResultsList = memo(function ResultsList({ results }) {
  return (
    <ul>
      {results.map((r) => (
        <li key={r.id}>{r.name}</li>
      ))}
    </ul>
  );
});

function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (value) => {
    setQuery(value);
    startTransition(() => setResults(expensiveSearch(value)));
  };

  return (
    <>
      <input value={query} onChange={(e) => handleSearch(e.target.value)} />
      {isPending && <Spinner />}
      <ResultsList results={results} />
    </>
  );
}

// The memo prevents the ResultsList from re-rendering when the results are unchanged.
// The transition keeps the input responsive.


// ============================================
// PART 7: THE ROUTER NAVIGATION
// ============================================

function Router() {
  const [page, setPage] = useState('/');
  const [isPending, startTransition] = useTransition();

  function navigate(url) {
    startTransition(() => {
      setPage(url);
    });
  }

  return (
    <Suspense fallback={<BigSpinner />}>
      {page === '/' && <IndexPage onNavigate={navigate} />}
      {page === '/artist' && <ArtistPage onNavigate={navigate} />}
    </Suspense>
  );
}

// The navigation is a transition, so the user can click away without waiting.


// ============================================
// PART 8: THE TRANSITION VERSUS THE DEBOUNCE
// ============================================

// THE DEBOUNCE:
function DebouncedSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    const timer = setTimeout(() => {
      setResults(expensiveSearch(query));
    }, 300);
    return () => clearTimeout(timer);
  }, [query]);

  return <input value={query} onChange={(e) => setQuery(e.target.value)} />;
}

// The 300ms delay before the search starts.

// THE TRANSITION:
function TransitionSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (value) => {
    setQuery(value);
    startTransition(() => setResults(expensiveSearch(value)));
  };

  return (
    <>
      <input value={query} onChange={(e) => handleSearch(e.target.value)} />
      {isPending && <Spinner />}
    </>
  );
}

// The search starts immediately, but it can be interrupted.
// The transition is for the CPU-bound work.
// The debounce is for the network requests.


// ============================================
// PART 9: THE NETWORK-REQUEST PITFALL
// ============================================

// ❌ The fetch inside the transition
function BrokenFetch() {
  const [data, setData] = useState(null);
  const [isPending, startTransition] = useTransition();

  const fetchData = () => {
    startTransition(async () => {
      const response = await fetch('/api/data');
      const result = await response.json();
      setData(result); // The isPending is false while the request is in flight
    });
  };

  return <button onClick={fetchData}>Fetch</button>;
}

// ✅ The fetch outside, the state update inside
function WorkingFetch() {
  const [data, setData] = useState(null);
  const [isPending, startTransition] = useTransition();

  const fetchData = async () => {
    const response = await fetch('/api/data');
    const result = await response.json();
    startTransition(() => {
      setData(result); // The transition is for the state update
    });
  };

  return <button onClick={fetchData}>Fetch</button>;
}

// The transition is for the CPU-bound work, not for the I/O.
// Use the Suspense for the data fetching.


// ============================================
// PART 10: THE COMPLETE EXAMPLE
// ============================================

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

const ExpensiveList = memo(function ExpensiveList({ items }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
});

function SearchApp({ allItems }) {
  const [query, setQuery] = useState('');
  const [filteredItems, setFilteredItems] = useState(allItems);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (value) => {
    setQuery(value); // Urgent: update the input immediately

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

  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="Search..."
      />
      {isPending && <div className="spinner">Filtering...</div>}
      <div style={{ opacity: isPending ? 0.6 : 1 }}>
        <ExpensiveList items={filteredItems} />
      </div>
    </div>
  );
}

// The input stays responsive.
// The filtering happens in the background and can be interrupted.
// The isPending shows the subtle loading state.
// The memo prevents the unnecessary re-renders.

The ten parts show the basic transition, the isPending indicator, the standalone startTransition, the tab navigation, the controlled input pitfall, the transition and the memo, the router navigation, the transition versus the debounce, the network-request pitfall, and the complete example.


Quick Reference

The useTransition API

ElementMeaning
[isPending, startTransition]The hook’s return
isPendingThe boolean; the transition is in progress
startTransition(callback)The function that marks the updates
startTransition (standalone)The import without the hook

The Transition Rules

RuleMeaning
The urgent updateOutside the transition
The non-urgent updateInside the transition
The controlled inputNever inside the transition
The network requestOutside the transition
The multiple updatesBatched in one transition

The useTransition vs useDeferredValue

AspectuseTransitionuseDeferredValue
The controlThe state setterThe value
The sourceThe componentThe props or the external
The pendingThe isPendingThe value !== deferredValue
The use caseThe expensive state updateThe expensive child render

The Transition vs Debounce

AspectTransitionDebounce
The delayNone; the dynamicThe fixed timer
The inputAlways instantThe potentially laggy
The loadingThe built-in isPendingThe manual
The cancellationThe automaticThe manual
The use caseThe CPU-boundThe network requests

The Patterns

PatternThe hook
The searchThe useTransition
The tab navigationThe useTransition
The routerThe useTransition
The expensive childThe useDeferredValue
The data fetchingThe Suspense

Best Practices

✅ Do This:

// Keep the input state outside the transition
setQuery(value);
startTransition(() => setFilteredQuery(value));                          // ✅
// Use the isPending for the subtle loading indicator
{isPending && <Spinner />}                                                // ✅
// Use the memo for the expensive child
const ResultsList = memo(function ResultsList({ results }) { ... });      // ✅
// Use the transition for the CPU-bound work
startTransition(() => setResults(expensiveSearch(value)));                // ✅
// Use the debounce for the network requests
const debouncedSearch = useCallback(debounce(...), []);                   // ✅
// Use the router navigation as a transition
startTransition(() => setPage(url));                                      // ✅

❌ Don’t Do This:

// Don't wrap the controlled input state in the transition
startTransition(() => setText(e.target.value));  // the input lags           // ❌
// Don't use the transition for the network requests
startTransition(async () => { await fetch(...); });  // the isPending is wrong // ❌
// Don't use the transition for the cheap updates
startTransition(() => setCount((c) => c + 1));  // the overhead              // ❌
// Don't forget the memo for the expensive child
<ExpensiveList results={results} />  // the re-render defeats the transition  // ❌
// Don't rely on the transition to make the code faster
// The transition prioritizes; it does not optimize                     // ❌
// Don't use the transition when the state update is not the bottleneck
// The profiler is the guide                                            // ❌

Common Pitfalls

PitfallWhy It HappensFix
The input lagsThe controlled state in the transitionMove it outside
The isPending is wrongThe network request in the transitionUse the Suspense
The transition has no effectThe missing memoMemoize the child
The transition is slowerThe cheap updateRemove the transition
The double renderThe isPending behaviorThe expected; the cost is small
The stale resultsThe interruptThe expected; the new transition restarts
The transition is not interruptibleThe synchronous workBreak the work into the chunks

Real-World Examples

1. The Search

setQuery(value);
startTransition(() => setResults(filter(value)));

2. The Tab

startTransition(() => setActiveTab(nextTab));

3. The Router

startTransition(() => setPage(url));

4. The isPending

{isPending && <Spinner />}

5. The Memo

const ResultsList = memo(function ResultsList({ results }) { ... });

6. The Standalone

import { startTransition } from 'react';
startTransition(() => setState(next));

7. The Deferred Value

const deferredQuery = useDeferredValue(query);

8. The Suspense

<Suspense fallback={<Spinner />}><Results /></Suspense>

9. The Transition with the Tab

startTransition(() => setTab(next));

10. The Complete

setQuery(value);
startTransition(() => setResults(expensiveSearch(value)));

Visual

The Transition Scheduling

┌──────────────────────────────────────────────────────────────┐
│  THE TRANSITION SCHEDULING                                   │
│                                                              │
│  The user types "r":                                         │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  1. setQuery('r')  → the urgent lane                 │    │
│  │     The input updates IMMEDIATELY.                    │    │
│  │                                                       │    │
│  │  2. startTransition(() => setResults(...))           │    │
│  │     → the transition lane                            │    │
│  │     The filtering starts.                             │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  The user types "e" before the filtering completes:          │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  1. setQuery('re')  → the urgent lane                │    │
│  │     The input updates IMMEDIATELY.                    │    │
│  │                                                       │    │
│  │  2. React INTERRUPTS the "r" transition.             │    │
│  │     The "r" filtering is ABANDONED.                  │    │
│  │                                                       │    │
│  │  3. startTransition(() => setResults(...))           │    │
│  │     → the transition lane                            │    │
│  │     The "re" filtering starts.                        │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  The input is never blocked. The user sees every keystroke.  │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The Double Render

┌──────────────────────────────────────────────────────────────┐
│  THE DOUBLE RENDER                                           │
│                                                              │
│  The user types a character:                                 │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  1. The URGENT render:                               │    │
│  │     setQuery(value)                                  │    │
│  │     The input updates.                               │    │
│  │     isPending becomes true.                          │    │
│  │     The UI shows the pending state.                  │    │
│  └──────────────────────────────────────────────────────┘    │
│    │                                                         │
│    ▼                                                         │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  2. The TRANSITION render:                           │    │
│  │     setResults(expensiveSearch(value))               │    │
│  │     The results update.                              │    │
│  │     isPending becomes false.                         │    │
│  │     The UI shows the final state.                    │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  The double render is the mechanism for the isPending.       │
│  The first render is the immediate, the second is the        │
│  deferred.                                                   │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The Transition vs the Debounce

┌──────────────────────────────────────────────────────────────┐
│  THE TRANSITION vs THE DEBOUNCE                              │
│                                                              │
│  THE DEBOUNCE:                                               │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  The user types "r":                                 │    │
│  │    setQuery('r')                                     │    │
│  │    The timer starts (300ms).                         │    │
│  │                                                       │    │
│  │  The user types "e" before the timer:                │    │
│  │    The timer RESETS.                                 │    │
│  │    The search for "r" never starts.                  │    │
│  │                                                       │    │
│  │  The user stops typing:                              │    │
│  │    The timer completes.                              │    │
│  │    The search for "re" starts.                       │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  THE TRANSITION:                                             │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  The user types "r":                                 │    │
│  │    setQuery('r')                                     │    │
│  │    The search for "r" STARTS.                        │    │
│  │                                                       │    │
│  │  The user types "e":                                 │    │
│  │    The search for "r" is INTERRUPTED.                │    │
│  │    The search for "re" STARTS.                       │    │
│  │                                                       │    │
│  │  The user stops typing:                              │    │
│  │    The search for "re" completes.                    │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  The debounce waits. The transition starts immediately.      │
│                                                              │
└──────────────────────────────────────────────────────────────┘

The Controlled Input Pitfall

┌──────────────────────────────────────────────────────────────┐
│  THE CONTROLLED INPUT PITFALL                                │
│                                                              │
│  ❌ THE INPUT STATE IN THE TRANSITION:                        │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  startTransition(() => {                             │    │
│  │    setText(e.target.value);                          │    │
│  │  });                                                 │    │
│  │                                                       │    │
│  │  The input value is a transition.                    │    │
│  │  The input LAGS behind the keystrokes.               │    │
│  │  The user sees the delay.                            │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
│  ✅ THE INPUT STATE OUTSIDE:                                  │
│  ┌──────────────────────────────────────────────────────┐    │
│  │  setText(e.target.value);  // The urgent update      │    │
│  │                                                       │    │
│  │  startTransition(() => {                             │    │
│  │    setFilteredText(e.target.value);  // The transition│   │
│  │  });                                                 │    │
│  │                                                       │    │
│  │  The input updates IMMEDIATELY.                      │    │
│  │  The filtering happens in the background.            │    │
│  └──────────────────────────────────────────────────────┘    │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
useTransitionThe hook for the non-urgent updates
startTransitionThe function that marks the updates
isPendingThe boolean for the transition in progress
The double renderThe immediate and the deferred
The lane schedulingThe high and low priority
The controlled inputNever inside the transition
The network requestOutside the transition; use the Suspense
The memoThe companion for the expensive child
The debounceFor the network requests
The transitionFor the CPU-bound work

Key takeaways:

  • useTransition marks a state update as non-urgent and interruptible. The urgent updates run immediately, and the transition runs in the background. If the user interacts again, the in-progress transition is interrupted, and the new input is processed first .
  • The hook returns the isPending and the startTransition. The isPending is the boolean for the pending state, and the startTransition is the function that wraps the updates. The standalone startTransition is available without the hook .
  • The double render is the mechanism for the isPending. The first render is the immediate, where the isPending becomes true, and the second is the deferred, where the transition’s state updates and the isPending becomes false .
  • The controlled input state must stay outside the transition. The setText inside the startTransition causes the input to lag behind the keystrokes. The fix is the two state variables: the text for the input, and the filteredText for the expensive rendering .
  • The network requests are not the transition’s use case. The transition does not wait for the promises, and the isPending is wrong. The transition is for the CPU-bound work, and the Suspense is for the data fetching .
  • The React.memo is the companion. Without the memo, the child re-renders when the parent re-renders, and the transition’s benefit is lost. The memo prevents the unnecessary re-renders, and the transition keeps the input responsive .
  • The transition is not the debounce. The debounce delays the work with a fixed timer, and the transition starts the work immediately but allows it to be interrupted. The debounce is for the network requests, and the transition is for the CPU-bound work .
  • The transition is for the expensive CPU-bound updates. The setCount is already fast, and the transition adds the overhead. The profiler is the guide, and the transition should be added only when the measurement shows the problem .

Remember: useTransition is the hook that marks a state update as non-urgent and interruptible. The urgent updates run immediately, and the transition runs in the background. The isPending is the pending indicator, and the startTransition is the function that wraps the updates. The controlled input state stays outside the transition, the network requests use the Suspense, and the React.memo is the companion. The result is the responsive interface that keeps the input smooth while the expensive work happens in the background, which is the foundation of the modern React performance.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!