useTransition and useDeferredValue in React
useTransition marks state updates as low-priority work React can interrupt; useDeferredValue defers a value's re-render until urgent updates commit first.
Theory
TL;DR
- Analogy:
useTransitionis like telling React "filter this list in the background while I keep typing" - input stays instant, results catch up after. useTransitionwraps state setters and returns[isPending, startTransition];useDeferredValuewraps a value and returns a deferred copy.- Own the state setter: use
useTransition. Receive a value as a prop: useuseDeferredValue. - Need a loading indicator: only
useTransitionhasisPending.useDeferredValuedoes not. - Both require React 18 with concurrent rendering enabled.
Quick example
import { useState, useTransition } from 'react';
function Search() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
setQuery(e.target.value); // Urgent: commits before next paint
startTransition(() => {
setResults(heavySearch(e.target.value)); // Low-priority: yields to input
});
};
return (
<div>
<input value={query} onChange={handleChange} />
{isPending && <p>Searching...</p>}
<ul>{results.map(r => <li key={r}>{r}</li>)}</ul>
</div>
);
}Type fast and the input updates on every keystroke. isPending is true while the background filter runs. Results appear once typing slows.
Key difference
useTransition requires access to the state setter. You call startTransition(() => setState(...)) and get isPending back. useDeferredValue only needs the value itself, making it the right pick when a child component receives a prop from a parent it does not own. No isPending signal, but you can detect staleness by comparing the original value to the deferred copy (query !== deferredQuery).
When to use
- Typing in a search field with heavy client-side filtering:
useTransition(wrapsetResults). - A child component renders a large list from a prop:
useDeferredValue(defer the list re-render). - Tab switch that triggers a heavy render:
useTransition(non-urgent tab change withisPendingfor disabled buttons). - Debounced display without debouncing the input:
useDeferredValue(input commits instantly, results lag). - You need a spinner:
useTransition. No spinner needed: either works, butuseDeferredValueis simpler.
Comparison table
| useTransition | useDeferredValue | |
|---|---|---|
| What it defers | A state update | A value |
| Where to apply | You control the setter | You only have the value |
| Returns | [isPending, startTransition] | Deferred copy of the value |
| Pending indicator | Built-in isPending | Compare original vs deferred manually |
| Typical location | Event handler | Component body or child component |
How React schedules this internally
React 18 assigns every update a "lane" inside the fiber reconciler. Urgent updates (input events) get SyncLane or DefaultLane. Anything wrapped in startTransition gets TransitionLane, which has lower priority. During the render loop, the scheduler runs shouldYield() after each unit of work. If a higher-priority lane has pending work, the low-priority render pauses and resumes later.
useDeferredValue uses the same lane system without touching the setter. React renders the component twice: once with the current value (urgent path), once with the deferred value (low-priority path). The deferred render can be interrupted at any point. This is React's own fiber scheduler, not requestIdleCallback.
Common mistakes
1. Treating isPending as an empty-state check
// Wrong: spins even when results are genuinely empty
{results.length === 0 && <Spinner />}
// Right: spins only during an active transition
{isPending && <Spinner />}2. Wrapping the input setter inside startTransition
The most common mistake I catch in code review: putting the input state update inside the transition.
// Wrong: input lags, defeats the purpose
startTransition(() => {
setQuery(e.target.value);
});
// Right: input outside, heavy work inside
setQuery(e.target.value);
startTransition(() => setResults(heavySearch(e.target.value)));3. Assuming useDeferredValue cancels expensive work
// Wrong assumption
const deferred = useDeferredValue(expensiveCompute(query));
// expensiveCompute still runs on every render regardlessDeferring only changes when React commits the render. expensiveCompute(query) still runs. Wrap the expensive call in useMemo keyed to the deferred value, or handle async work with an abort controller.
4. Nesting startTransition calls
// Wrong: confusing priority
startTransition(() => {
startTransition(() => setDeepState(val));
});Use one outer startTransition. Nesting does not increase priority and can cause unpredictable pending states.
5. Ignoring StrictMode double-invoke behavior
In development with StrictMode, React 18 double-invokes state updaters inside transitions. isPending may flicker. This is expected in dev mode and does not appear in production builds.
Real-world usage
- React DevTools:
useTransitionfor filter toggles in the component tree panel. - TanStack Query v5: wraps list refetch after mutations in transitions.
- Next.js App Router:
useDeferredValuedefers search results in dynamic route segments. - Vercel Commerce template: search debouncing via deferred query against a large product catalog.
Follow-up questions
Q: What is the difference between useTransition and the standalone startTransition imported from react?
A: Standalone startTransition does the same job but returns nothing. Use it in utility functions or event handlers outside components where you do not need isPending.
Q: How does useDeferredValue interact with Suspense?
A: If the deferred render reads from a suspended resource, React shows the stale UI instead of the Suspense fallback until the deferred render finishes. This avoids a flash-to-spinner on every keystroke.
Q: Why might isPending stay true for a long time?
A: A new transition starting before the previous one resolves keeps the flag true. An unhandled error inside the transition callback also blocks the queue.
Q: What lane priorities does the React scheduler use internally?
A: Urgent input: SyncLane or DefaultLane. startTransition work: TransitionLane. Background tasks: IdleLane. The reconciler yields whenever shouldYield() returns true, which happens when a higher-priority lane has pending work.
Q: How would you implement transition preemption in a custom scheduler? Walk through fiber lane assignment.
A: Assign a low-priority lane to the transitioning fiber during scheduleUpdateOnFiber. In the work loop, check shouldYield() after each fiber unit. If a higher lane has work, break and post a new task for that lane. The in-progress low-priority work stays in the tree and resumes after the high-priority pass completes. React does not discard partial work, it suspends it.
Examples
Basic: filtering a large list with useTransition
import { useState, useTransition } from 'react';
const items = Array.from({ length: 10000 }, (_, i) => `Product ${i + 1}`);
function ProductList() {
const [query, setQuery] = useState('');
const [results, setResults] = useState(items);
const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value); // Commits immediately, before filter runs
startTransition(() => {
setResults(
items.filter(item => item.toLowerCase().includes(value.toLowerCase()))
);
});
};
return (
<div>
<input value={query} onChange={handleChange} placeholder="Search..." />
{isPending && <p>Filtering...</p>}
<ul>
{results.map(item => <li key={item}>{item}</li>)}
</ul>
</div>
);
}The input field commits on every keystroke without waiting for the 10k-item filter. isPending shows a message while the background render works. No debounce needed.
Intermediate: useDeferredValue in a child component
This pattern appears in e-commerce templates where a child component only receives query as a prop:
import { useState, useDeferredValue, useMemo } from 'react';
interface Product {
id: number;
name: string;
}
function ProductResults({ query, products }: { query: string; products: Product[] }) {
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery; // True while deferred lags behind
const filtered = useMemo(
() => products.filter(p =>
p.name.toLowerCase().includes(deferredQuery.toLowerCase())
),
[products, deferredQuery] // Only re-runs when deferredQuery changes
);
return (
<ul style={{ opacity: isStale ? 0.6 : 1, transition: 'opacity 0.15s' }}>
{filtered.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
function App({ products }: { products: Product[] }) {
const [query, setQuery] = useState('');
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
<ProductResults query={query} products={products} />
</div>
);
}ProductResults does not own the setter, so useTransition is not an option here. The opacity fade signals to the user that a fresher result is on the way.
Advanced: combining both hooks in one component
In production you often need both at once. useTransition for a tab switch (you control setTab), useDeferredValue for the query filter (you only need the value):
import { useState, useTransition, useDeferredValue, useMemo } from 'react';
type Tab = 'active' | 'archived';
interface Item {
id: number;
name: string;
status: Tab;
}
function AdminDashboard({ items }: { items: Item[] }) {
const [query, setQuery] = useState('');
const [tab, setTab] = useState<Tab>('active');
const [isPending, startTransition] = useTransition();
const deferredQuery = useDeferredValue(query);
const handleTabChange = (next: Tab) => {
startTransition(() => setTab(next)); // Non-urgent, but you own the setter
};
const visible = useMemo(
() =>
items
.filter(item => item.status === tab)
.filter(item =>
item.name.toLowerCase().includes(deferredQuery.toLowerCase())
),
[items, tab, deferredQuery]
);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} placeholder="Filter..." />
<button onClick={() => handleTabChange('active')} disabled={isPending}>
Active {isPending && '...'}
</button>
<button onClick={() => handleTabChange('archived')} disabled={isPending}>
Archived {isPending && '...'}
</button>
<ul style={{ opacity: query !== deferredQuery ? 0.7 : 1 }}>
{visible.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
</div>
);
}Tab buttons disable during the transition (isPending). The list uses deferredQuery so typing does not block the tab switch animation. Both hooks in the same component, each doing exactly the job it is designed for.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.