Must knowReact Suspense · 02

Promise Caching and Transitions

One promise per id, and startTransition to keep the old page instead of a fallback, without a flickering spinner.

On this page · 7 sections
  1. In one minute
  2. The example: a product switcher
  3. How it works
  4. Common mistakes
  5. Interview Q&A
  6. Related
  7. Sources

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.

setId (urgent)
ProductSwitcher.tsx
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}
What you see
Ceramic Mug
  1. event<button onClick={() => setId('p4')}>Desk Lamp</button>Click Desk Lamp.
  2. render phaseif (!productCache.has(id)) productCache.set(id, fetchProduct(id))The new id renders ProductDetails, which asks the cache for p4: a new request, so it suspends.
  3. 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.
  4. commit phaseThe data arrives and the lamp appears.

0 / 0
Switching from the Ceramic Mug to the Desk Lamp, whose request takes 500 ms (both versions recorded).

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

Common mistakes

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

Sources

Primary source: react.dev/reference/react/useTransition