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.

April 4, 20267 min read2 / 2

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:

TSX
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:

TSX
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:

TSX
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:

TSX
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/debounceuseDeferredValue
Adapts to device speedNo — fixed intervalYes — faster on fast devices
Cancels intermediate rendersNoYes — intermediate updates are skipped
Requires timing guessYesNo
Works with React schedulerNoYes

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:

TSX
async function postThought(thought) { await fetch('/api/thoughts', { method: 'POST', body: thought }); // User stares at a loading spinner for 500ms–2s refetchThoughts(); }

With useOptimistic:

TSX
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

Plain text
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 → useOptimistic

These 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.

Enjoyed this? Get more like it.

Deep dives on system design, React, web development, and personal finance — straight to your inbox. Free, always.