useTransition, useDeferredValue, and useOptimistic
useTransition, useDeferredValue, and useOptimistic — React's three tools for keeping UIs responsive while expensive or async work runs in the background.
React's concurrent features give you fine-grained control over render priority. Instead of blocking the UI until expensive work finishes, you can tell React: "this update is low priority — keep the UI interactive while you process it."
Three hooks do this. Each solves a slightly different version of the problem.
useTransition: Defer a State Update You Control
useTransition is for when you own the setter. It wraps a state update in a transition, telling React to process it at lower priority than urgent work (like typing into an input).
The problem without transitions:
function ScoreBoard() {
const [sport, setSport] = useState('football');
const [scores, setScores] = useState([]);
async function handleSportChange(newSport) {
setSport(newSport); // ← urgent: update the selected button
const data = await fetchScores(newSport); // ← slow: API call
setScores(data); // ← after slow fetch: updates the list
}
// If user clicks a different sport while data is loading,
// the old fetch and the new fetch race — results can arrive out of order
}Problems: while data loads, the UI feels stuck. If the user clicks again, the two fetches race and whichever resolves last wins — even if it's the older one.
With useTransition:
import { useState, useTransition } from 'react';
function ScoreBoard() {
const [sport, setSport] = useState('football');
const [scores, setScores] = useState([]);
const [isPending, startTransition] = useTransition();
async function handleSportChange(newSport) {
startTransition(async () => {
setSport(newSport);
const data = await fetchScores(newSport);
startTransition(() => {
setScores(data);
});
});
}
return (
<>
<nav>
{['football', 'basketball', 'baseball'].map(s => (
<button
key={s}
onClick={() => handleSportChange(s)}
style={{ opacity: isPending ? 0.6 : 1 }}
>
{s}
</button>
))}
</nav>
<ul>
{scores.map(score => <li key={score.id}>{score.label}</li>)}
</ul>
</>
);
}Why two startTransition calls? In large apps, state updates aren't instantaneous — re-rendering can take tens to hundreds of milliseconds. Wrapping each update gives React the opportunity to interrupt and process higher-priority work (like a new sport selection) before finishing the pending one.
isPending is true while any transition is in flight. Use it to show a subtle loading indicator without blocking interaction.
useDeferredValue: Defer a Value You Receive as a Prop
useDeferredValue is for when you don't own the setter — you receive a value from outside and want to defer the expensive work it triggers.
The problem:
function ImageEditor() {
const [blur, setBlur] = useState(0);
// Every slider drag re-runs the expensive filter synchronously
return (
<>
<Slider value={blur} onChange={setBlur} />
<ExpensiveImage blur={blur} /> {/* re-renders on every drag tick */}
</>
);
}Moving the slider fires dozens of events per second. Each one triggers ExpensiveImage to re-render with a heavy CSS filter calculation. The slider itself lags because it's waiting for the image to finish.
With useDeferredValue:
import { useState, useDeferredValue, memo } from 'react';
// memo prevents re-render unless deferredBlur actually changes
const ExpensiveImage = memo(function ExpensiveImage({ blur }) {
// Expensive: generates a complex filter style
const filterStyle = expensiveComputeFilter(blur);
return <img src="/photo.jpg" style={{ filter: filterStyle }} />;
});
function ImageEditor() {
const [blur, setBlur] = useState(0);
// blur updates immediately (slider is snappy)
// deferredBlur updates after React has time — lags behind blur
const deferredBlur = useDeferredValue(blur);
return (
<>
<Slider
value={blur}
deferred={deferredBlur} // show both so user sees the "catching up"
onChange={setBlur}
/>
{/* Only re-renders when deferredBlur settles */}
<ExpensiveImage blur={deferredBlur} />
</>
);
}The slider updates blur immediately on every tick — the input feels instant. deferredBlur lags behind: React updates it only when it has free capacity between higher-priority renders.
The key difference from useTransition: useDeferredValue is passive — you hand it a value and it gives you a deferred version. useTransition is active — you wrap the state setter call in startTransition.
useDeferredValue vs throttle/debounce
| throttle/debounce | useDeferredValue | |
|---|---|---|
| Adapts to device speed | No — fixed interval | Yes — faster on fast devices |
| Cancels intermediate renders | No | Yes — intermediate updates are skipped |
| Requires timing guess | Yes | No |
| Works with React scheduler | No | Yes |
useDeferredValue scales with the device. On a fast machine, deferred values update nearly immediately. On a slow phone, React gives them more breathing room — automatically.
useOptimistic: Instant Feedback for Async Actions
useOptimistic is for when you want the UI to reflect an action before the server confirms it — then automatically roll back if the action fails.
Without optimistic updates:
async function postThought(thought) {
await fetch('/api/thoughts', { method: 'POST', body: thought });
// User stares at a loading spinner for 500ms–2s
refetchThoughts();
}With useOptimistic:
import { useState, useOptimistic, useTransition } from 'react';
function DeepThoughts() {
const [thoughts, setThoughts] = useState([]);
const [thoughtInput, setThoughtInput] = useState('');
const [isPending, startTransition] = useTransition();
// useOptimistic(actualState, updateFn)
// updateFn receives (currentState, optimisticValue) and returns the optimistic state
const [optimisticThoughts, addOptimisticThought] = useOptimistic(
thoughts,
(oldThoughts, newThought) => [newThought, ...oldThoughts]
);
async function handlePost() {
const thought = thoughtInput;
setThoughtInput('');
startTransition(async () => {
// Show it immediately with a loading marker
addOptimisticThought(`${thought} (sending…)`);
// Actually post it
const res = await fetch('/api/thoughts', {
method: 'POST',
body: JSON.stringify({ thought }),
headers: { 'Content-Type': 'application/json' },
});
const saved = await res.json();
// Update the real state — optimistic version disappears automatically
setThoughts(prev => [saved, ...prev]);
});
}
return (
<>
<input value={thoughtInput} onChange={e => setThoughtInput(e.target.value)} />
<button onClick={handlePost} disabled={isPending}>Post</button>
<ul>
{optimisticThoughts.map((t, i) => (
<li key={i} style={{ opacity: t.includes('sending') ? 0.5 : 1 }}>{t}</li>
))}
</ul>
</>
);
}What happens on failure? If the await fetch throws, useOptimistic automatically rolls back to the last stable thoughts state. The "(sending…)" entry disappears and the real list is restored. No manual cleanup required.
Choosing the Right Hook
Do you own the setter that triggers the expensive work?
Yes → useTransition
Do you receive a value as a prop and want to defer downstream re-renders?
Yes → useDeferredValue
Do you want the UI to reflect an action before the server responds?
Yes → useOptimisticThese three hooks compose. A real app might use useTransition to wrap a server action, useOptimistic to show the result immediately, and useDeferredValue on an expensive derived value downstream.
Keep reading
Enjoyed this? Get more like it.
Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.