In one minute
When the data depends on props or state (the product id the shopper picked), the component has to get its promise during render, so it needs a cache: the same id must give back the same promise, or use suspends forever. A Map from id to promise is enough for the basics. The next problem is what the shopper sees while the next product loads. A normal state update that suspends replaces the current content with the fallback. Wrapped in startTransition, React keeps the current content (you can dim it with isPending) and swaps in the new page when it’s ready. And to avoid a pending indicator that flickers for fast requests, show it only if loading takes longer than a short delay.
You’ll be able to: cache promises by id, switch content without flashing a fallback, and keep a spinner from flickering.
1const productCache = new Map()2function getProduct(id) {3 if (!productCache.has(id)) productCache.set(id, fetchProduct(id))4 return productCache.get(id) // the same promise for the same id5}67function ProductSwitcher() {8 const [id, setId] = useState('p1')9 const [isPending, startTransition] = useTransition()10 return (<>11 <button onClick={() => setId('p4')}>Desk Lamp</button>12 <Suspense fallback={<p>Loading product…</p>}>13 <ProductDetails id={id} /> {/* use(getProduct(id)) */}14 </Suspense>15 </>)16}- event
<button onClick={() => setId('p4')}>Desk Lamp</button>Click Desk Lamp. - render phase
if (!productCache.has(id)) productCache.set(id, fetchProduct(id))The new id rendersProductDetails, which asks the cache for p4: a new request, so it suspends. - commit phase
<Suspense fallback={<p>Loading product…</p>}>An urgent update that suspends replaces the content with the fallback: the mug disappears for 500 ms. - commit phaseThe data arrives and the lamp appears.
1const productCache = new Map()2function getProduct(id) {3 if (!productCache.has(id)) productCache.set(id, fetchProduct(id))4 return productCache.get(id) // the same promise for the same id5}67function ProductSwitcher() {8 const [id, setId] = useState('p1')9 const [isPending, startTransition] = useTransition()10 return (<>11 <button onClick={() => startTransition(() => setId('p4'))}>Desk Lamp</button>12 <Suspense fallback={<p>Loading product…</p>}>13 <ProductDetails id={id} /> {/* use(getProduct(id)) */}14 </Suspense>15 </>)16}- event
<button onClick={() => startTransition(() => setId('p4'))}>Desk Lamp</button>Click Desk Lamp.isPendingbecomes true right away, so the page can dim itself. - render phase
if (!productCache.has(id)) productCache.set(id, fetchProduct(id))The transition render suspends on the new product… - render phase
<Suspense fallback={<p>Loading product…</p>}>…and React keeps the current content instead of showing the fallback: a transition doesn’t hide content that’s already on screen. - commit phase500 ms later, the new content replaces the old in one step. (Going back to the cached mug made no request.)
The example: a product switcher
Three buttons switch between the Ceramic Mug (p1), the Desk Lamp (p4, a 500 ms request) and the Notebook Set (p6, a fast 100 ms request).
1. One promise per product
const productCache = new Map<string, Promise<Details>>()
function getProduct(id: string) {
let promise = productCache.get(id)
if (!promise) {
promise = fetchProduct(id, id === 'p6' ? 100 : 500) // p6 is a fast request
productCache.set(id, promise)
}
return promise
}
function ProductDetails({ id }: { id: string }) {
const product = use(getProduct(id)) // the same promise for the same id
return <h2 data-details={product.name}>{product.name}</h2>
}
getProduct isn’t async: an async function returns a new promise every call, even when it returns a cached one inside. Returning the cached promise object itself is what lets use recognize it.
Going back to the mug, which is already cached, made no request and showed no fallback (from the urgent version below):
screen: details: Ceramic Mug
2. An urgent update
function ProductSwitcherUrgent() {
const [id, setId] = useState('p1')
return (
<>
{ids.map((x) => (
<button key={x} id={x} onClick={() => setId(x)}>{x}</button>
))}
<Suspense fallback={<p data-fallback>Loading product…</p>}>
<ProductDetails id={id} />
</Suspense>
</>
)
}
Clicking the Desk Lamp:
1600 ms → GET /products/p4
screen: fallback “Loading product…”
2100 ms ← GET /products/p4
screen: details: Desk Lamp
The mug disappeared and “Loading product…” took its place for 500 ms. For a page that was already showing content, that’s a jarring blank.
3. A transition
function ProductSwitcher() {
const [id, setId] = useState('p1')
const [isPending, startTransition] = useTransition()
return (
<>
{ids.map((x) => (
<button key={x} id={x} onClick={() => startTransition(() => setId(x))}>{x}</button>
))}
{isPending && <span data-pending>⏳</span>}
<div style={{ opacity: isPending ? 0.6 : 1 }} data-stale={isPending || undefined}>
<Suspense fallback={<p data-fallback>Loading product…</p>}>
<ProductDetails id={id} />
</Suspense>
</div>
</>
)
}
screen: pending ⏳ · details: Ceramic Mug (dimmed)
1650 ms → GET /products/p4
2150 ms ← GET /products/p4
screen: details: Desk Lamp
The mug stayed, dimmed, with ⏳, until the lamp was ready. But look at the fast and cached switches:
screen: pending ⏳ · details: Desk Lamp (dimmed)
screen: details: Ceramic Mug
screen: pending ⏳ · details: Ceramic Mug (dimmed)
2900 ms → GET /products/p6
3000 ms ← GET /products/p6
screen: details: Notebook Set
The ⏳ flashed even for the already-cached mug: isPending turns true in an urgent render first, then false again. That one-frame flash is a flicker of its own.
4. Hiding quick spinners
// Show a pending state only if it lasts longer than `delay`; once shown,
// keep it for at least `minDuration` (the idea behind the spin-delay package).
function useSpinDelay(loading: boolean, { delay = 300, minDuration = 400 } = {}) {
const [show, setShow] = useState(false)
const shownAt = useRef(0)
useEffect(() => {
if (loading && !show) {
const t = setTimeout(() => ((shownAt.current = Date.now()), setShow(true)), delay)
return () => clearTimeout(t)
}
if (!loading && show) {
const t = setTimeout(() => setShow(false), Math.max(0, minDuration - (Date.now() - shownAt.current)))
return () => clearTimeout(t)
}
}, [loading, show, delay, minDuration])
return show
}
function ProductSwitcherSpinDelay() {
const [id, setId] = useState('p1')
const [isPending, startTransition] = useTransition()
const showSpinner = useSpinDelay(isPending)
return (
<>
{ids.map((x) => (
<button key={x} id={x} onClick={() => startTransition(() => setId(x))}>{x}</button>
))}
{showSpinner && <span data-pending>⏳</span>}
<Suspense fallback={<p data-fallback>Loading product…</p>}>
<ProductDetails id={id} />
</Suspense>
</>
)
}
Fast and cached switches showed no ⏳ at all:
2800 ms → GET /products/p6
2900 ms ← GET /products/p6
screen: details: Notebook Set
The slow switch still showed it, and kept it briefly after the lamp appeared, so it doesn’t blink:
1600 ms → GET /products/p4
screen: pending ⏳ · details: Ceramic Mug
2100 ms ← GET /products/p4
screen: pending ⏳ · details: Desk Lamp
screen: details: Desk Lamp
The spin-delay package’s useSpinDelay does the same, with tested edge cases.
How it works
- Transitions don’t hide revealed content. If an update inside
startTransitionsuspends under aSuspenseboundary that’s already showing content, React keeps that content and waits. Boundaries that are new in the update can still show their fallback (Suspending on Images). isPendingis true from the click until the transition commits. It’s the place for a lightweight indicator (dimming, a small spinner), instead of the full fallback.- The cache here never invalidates. A real app needs to refresh data, limit memory and handle errors (a rejected promise stays cached). Data libraries handle that; this note shows the core idea.
Common mistakes
asynccache functions. A new promise every call defeats the cache.- Caching by an object key (
cache.get({ id })): never the same key twice. Use a string. - Urgent updates for navigation between already-loaded screens: the fallback replaces the content.
- Showing
isPendingdirectly for requests that are often fast: a flickering spinner.
Interview Q&A
Why does calling a fetch function directly in a component break Suspense?
Each render creates a new promise. use sees a pending promise every time, suspends, and the retry creates another one, so it never finishes. (Data Fetching with use recorded 12 requests in 2.5 seconds.)
How do you fix it?
Cache the promise by its input (Map<id, Promise>), and return the same promise object for the same id. Recorded: switching back to a cached product made no request.
Why shouldn't the cache function be async?
async functions always return a new promise wrapping the result, so use would see a different promise each call.
What does useTransition change when an update suspends?
Instead of replacing the visible content with the fallback, React keeps it and sets isPending until the new content is ready. Recorded: the mug stayed (dimmed) for 500 ms, then the lamp replaced it.
Why can a transition still flicker, and how do you fix it?
isPending turns on even for instant updates, so a pending indicator can flash for one frame. Recorded: ⏳ flashed when switching to a cached product. Show it only after a delay, and then for a minimum time (useSpinDelay).
Related
- Data Fetching with use:
use,Suspenseand error boundaries. - Code Splitting: the same transition trick for lazy code.
- Concurrent Rendering: transitions and priorities.
Sources
- react.dev:
useTransition, Preventing already revealed content from hiding - spin-delay
- Topic order inspired by Kent C. Dodds’ EpicReact React Suspense workshop; the example app and code here are this handbook’s own.
Primary source: react.dev/reference/react/useTransition