React 50 ⚛️ Non-Blocking Rendering with useTransition
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
| Element | Meaning |
|---|---|
[isPending, startTransition] | The hook’s return |
isPending | The boolean; the transition is in progress |
startTransition(callback) | The function that marks the updates |
startTransition (standalone) | The import without the hook |
The Transition Rules
| Rule | Meaning |
|---|---|
| The urgent update | Outside the transition |
| The non-urgent update | Inside the transition |
| The controlled input | Never inside the transition |
| The network request | Outside the transition |
| The multiple updates | Batched in one transition |
The useTransition vs useDeferredValue
| Aspect | useTransition | useDeferredValue |
|---|---|---|
| The control | The state setter | The value |
| The source | The component | The props or the external |
| The pending | The isPending | The value !== deferredValue |
| The use case | The expensive state update | The expensive child render |
The Transition vs Debounce
| Aspect | Transition | Debounce |
|---|---|---|
| The delay | None; the dynamic | The fixed timer |
| The input | Always instant | The potentially laggy |
| The loading | The built-in isPending | The manual |
| The cancellation | The automatic | The manual |
| The use case | The CPU-bound | The network requests |
The Patterns
| Pattern | The hook |
|---|---|
| The search | The useTransition |
| The tab navigation | The useTransition |
| The router | The useTransition |
| The expensive child | The useDeferredValue |
| The data fetching | The 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The input lags | The controlled state in the transition | Move it outside |
The isPending is wrong | The network request in the transition | Use the Suspense |
| The transition has no effect | The missing memo | Memoize the child |
| The transition is slower | The cheap update | Remove the transition |
| The double render | The isPending behavior | The expected; the cost is small |
| The stale results | The interrupt | The expected; the new transition restarts |
| The transition is not interruptible | The synchronous work | Break 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
| Item | Value |
|---|---|
useTransition | The hook for the non-urgent updates |
startTransition | The function that marks the updates |
isPending | The boolean for the transition in progress |
| The double render | The immediate and the deferred |
| The lane scheduling | The high and low priority |
| The controlled input | Never inside the transition |
| The network request | Outside the transition; use the Suspense |
| The memo | The companion for the expensive child |
| The debounce | For the network requests |
| The transition | For the CPU-bound work |
Key takeaways:
useTransitionmarks 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
isPendingand thestartTransition. TheisPendingis the boolean for the pending state, and thestartTransitionis the function that wraps the updates. The standalonestartTransitionis available without the hook . - The double render is the mechanism for the
isPending. The first render is the immediate, where theisPendingbecomestrue, and the second is the deferred, where the transition’s state updates and theisPendingbecomesfalse. - The controlled input state must stay outside the transition. The
setTextinside thestartTransitioncauses the input to lag behind the keystrokes. The fix is the two state variables: thetextfor the input, and thefilteredTextfor the expensive rendering . - The network requests are not the transition’s use case. The transition does not wait for the promises, and the
isPendingis wrong. The transition is for the CPU-bound work, and the Suspense is for the data fetching . - The
React.memois 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
setCountis 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!