HomeAbout Me

React Suspense: Dynamic Promises

By Daniel Nguyen
Published in React JS
June 22, 2025
2 min read
React Suspense: Dynamic Promises

🚀 Mastering Dynamic Promises in React: Caching, Transitions & UX Optimization

When building modern React applications, fetching data and displaying it efficiently is critical for providing a great user experience. But if you’re not careful, data-fetching logic can introduce unnecessary network requests, UI flickering, or even infinite loops. Let’s walk through how to manage promises dynamically in React, optimize performance with caching, and use transition APIs to smooth out the user experience.


📦 The Problem: Re-fetching on Every Render

Let’s start with a simple example:

async function fetchUser() {
const response = await fetch('/api/user')
const user = await response.json()
return user
}
function NumberInfo() {
const [count, setCount] = useState(0)
const userInfo = use(fetchUser())
const increment = () => setCount(c => c + 1)
return (
<div>
Hi {userInfo.name}! You have clicked {count} times.
<button onClick={increment}>Increment again!</button>
</div>
)
}

Every time you click the button, React re-renders the component, which calls fetchUser again. This triggers a new network request and causes the component to suspend, resulting in a janky experience for users.


✅ The Fix: Cache the Promise

React can track a single promise throughout the component lifecycle. So, the goal is to cache the promise so we don’t trigger unnecessary fetches:

let userPromise: Promise<User>
function fetchUser() {
userPromise = userPromise ?? fetchUserImpl()
return userPromise
}
async function fetchUserImpl() {
const response = await fetch('/api/user')
const user = await response.json()
return user
}

Now, every call to fetchUser() will return the same promise unless we explicitly invalidate the cache.


🔁 Dynamic Caching: Based on Arguments

Let’s say we want to fetch different users by ID. We’ll need a cache keyed by ID:

const userPromiseCache = new Map<string, Promise<User>>()
function fetchUser(id: string) {
let promise = userPromiseCache.get(id)
if (!promise) {
promise = fetchUserImpl(id)
userPromiseCache.set(id, promise)
}
return promise
}
async function fetchUserImpl(id: string) {
const response = await fetch(`/api/user/${id}`)
const user = await response.json()
return user
}

This approach lets us reuse promises and avoid suspending every time we change state or refetch the same data.

💡 Tip: If you need to reset the cache (e.g., during development), simply refresh the page.


✨ Improving UX with useTransition

Let’s say you allow users to switch between different items (e.g., starpokemons in a UI). Switching causes the component to suspend and display a fallback UI.

This isn’t ideal, especially if the data loads quickly—users may still see a flash of a loading spinner.

useTransition to the Rescue!

const [isPending, startTransition] = useTransition()
function handlePokemonChange(newPokemon: string) {
startTransition(() => {
setSelectedPokemon(newPokemon)
})
}

With useTransition, React keeps the old UI visible while the new data is loading. Meanwhile, the isPending flag helps you show a subtle pending indicator (like fading opacity).

<div style={{ opacity: isPending ? 0.6 : 1 }}>
<PokemonDetails pokemon={selectedPokemon} />
</div>

This results in a much smoother experience compared to instantly switching to a spinner.


🔄 Avoiding Flashy Spinners with spin-delay

Sometimes, loading is so fast (~50ms) that a spinner appears for a blink, which is visually jarring. Instead, you can use spin-delay to:

  • Wait before showing a spinner if the fetch is quick
  • Guarantee the spinner stays visible for a minimum time if shown
import { useSpinDelay } from 'spin-delay'
const [isPending, startTransition] = useTransition()
const showSpinner = useSpinDelay(isPending, {
delay: 300, // don’t show spinner if it loads faster than 300ms
minDuration: 350 // keep spinner visible for at least 350ms
})

This small tweak can eliminate the “flash of spinner” problem entirely—making your UI feel faster, smoother, and more polished.


🛠 Use Case: Pokemon Viewer with Cache + Transitions

Imagine a UI where users can select starpokemons and view their details. We want:

  • 🚫 No re-fetching the same pokemon
  • 🔄 Smooth transitions between pokemon details
  • ⚡ No flickering spinner on fast networks

You can achieve this by combining:

  • A cache using Map<string, Promise<Pokemon>>
  • useTransition to defer state updates
  • useSpinDelay to smartly show/hide spinners

🎓 Key Takeaways

  • Avoid fetching data on every render by caching promises.
  • Use useTransition to preserve old UI while new data is loading.
  • Enhance loading UX with useSpinDelay to avoid flickers and provide graceful transitions.
  • Combine these techniques for high-performance, responsive UIs that feel great to use.

📚 Further Reading



Tags

#ReactSuspense

Share

Previous Article
React Suspense: Data fetching

Table Of Contents

1
📦 The Problem: Re-fetching on Every Render
2
✅ The Fix: Cache the Promise
3
🔁 Dynamic Caching: Based on Arguments
4
✨ Improving UX with useTransition
5
🔄 Avoiding Flashy Spinners with spin-delay
6
🛠 Use Case: Pokemon Viewer with Cache + Transitions
7
🎓 Key Takeaways
8
📚 Further Reading

Related Posts

React Suspense: Optimizations
June 26, 2025
2 min
© 2025, All Rights Reserved.
Powered By

Quick Links

About Me

Legal Stuff

Social Media