🚀 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.
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.
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.
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.
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.
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:
import { useSpinDelay } from 'spin-delay'const [isPending, startTransition] = useTransition()const showSpinner = useSpinDelay(isPending, {delay: 300, // don’t show spinner if it loads faster than 300msminDuration: 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.
Imagine a UI where users can select starpokemons and view their details. We want:
You can achieve this by combining:
Map<string, Promise<Pokemon>>
useTransition
to defer state updatesuseSpinDelay
to smartly show/hide spinnersuseTransition
to preserve old UI while new data is loading.useSpinDelay
to avoid flickers and provide graceful transitions.Quick Links
Legal Stuff
Social Media