🚀 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.
useTransitionLet’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-delaySometimes, 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.