useTransition() for Perceived Performance: Smarter UI Feedback Without Loading Spinners

Learn how React’s `useTransition()` and `isPending` state let you defer non-urgent updates—like data fetches or form submissions—while keeping the UI instantly responsive and visually coherent.
Why “Loading…” Isn’t Enough Anymore
Modern users expect interfaces that feel fast—even when the backend isn’t. A spinning spinner that freezes interactivity, blocks navigation, or blanks out content creates cognitive friction. It signals “wait,” not “I’m working with you.”
React’s useTransition() (introduced with Concurrent Rendering) solves this by letting you mark certain state updates as non-urgent. Instead of blocking the main thread or forcing immediate re-renders, React defers them—while preserving responsiveness for urgent interactions like typing, scrolling, or button presses.
Crucially, useTransition() gives you an isPending boolean to drive subtle, context-aware feedback—not full-screen loaders, but thoughtful transitions: dimmed buttons, skeleton placeholders, or progressive disclosures.
In this guide, we’ll build two realistic examples:
- A search input that debounces and transitions while fetching results
- A form submission that stays interactive during a slow API call
No third-party libraries. Just React 18+ and intentional UX.
How useTransition() Works (Briefly)
useTransition() returns two values:
const [isPending, startTransition] = useTransition();isPending:truewhile a transition is active (i.e., whilestartTransition’s callback is running and its resulting renders are being processed).startTransition(callback): wraps a state update in a low-priority transition. React will render the update as soon as possible, but won’t interrupt urgent updates.
Important: startTransition only affects state updates triggered inside the callback. It does not wrap async operations directly—it wraps the setState calls that result from them.
Example 1: Search with Deferred Results & Instant Typing
Imagine a search bar that fetches suggestions from an API. You want:
- Typing to feel instant (no lag)
- Suggestions to appear smoothly—even if the API takes 800ms
- No blank screen or jarring loader between keystrokes
Here’s how:
import { useState, useEffect, useTransition } from 'react';
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (!query.trim()) {
setResults([]);
return;
}
// Debounce manually (or use useCallback + useEffect cleanup)
const timer = setTimeout(() => {
startTransition(() => {
// Simulate API call — but note: fetch itself is *not* in startTransition
fetch(`/api/search?q=${encodeURIComponent(query)}`)
.then(res => res.json())
.then(data => {
// This setState *is* wrapped — so it's deferred
setResults(data.suggestions || []);
});
});
}, 300);
return () => clearTimeout(timer);
}, [query]);
return (
<div className="search-container">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
className="search-input"
/>
{/* Visual feedback: subtle opacity + skeleton while pending */}
{isPending && query && (
<div className="search-skeleton">
<div className="skeleton-line"></div>
<div className="skeleton-line short"></div>
<div className="skeleton-line short"></div>
</div>
)}
{!isPending && results.length > 0 && (
<ul className="search-results">
{results.map((item, i) => (
<li key={i} className="result-item">{item}</li>
))}
</ul>
)}
</div>
);
}
export default SearchBox;Notice what’s not happening:
- We’re not disabling the input (
disabled={isPending}), because typing remains fully responsive. - The skeleton appears only while the transition is active—not during the network request itself, but while React is preparing to render the new results.
- If the user types again before the first fetch resolves, the previous
startTransitionis superseded—no race conditions or stale renders.
This delivers perceived speed: the UI acknowledges input immediately, then layers in results gracefully.
Example 2: Form Submission That Doesn’t Freeze the World
Now consider a checkout form that submits to a slow payment gateway. Users shouldn’t lose the ability to:
- Edit their address mid-submit
- Toggle shipping options
- Scroll the page
Yet we still need to show something meaningful: not just “Submitting…”, but a visual cue that progress is underway without locking interaction.
import { useState, useTransition } from 'react';
function CheckoutForm() {
const [formData, setFormData] = useState({
email: '',
cardNumber: '',
expiry: '',
});
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Mark submission as pending *before* async work begins
startTransition(() => {
setSubmitStatus('idle'); // reset status
});
// Now do the async work outside startTransition
submitPayment(formData)
.then(() => {
startTransition(() => {
setSubmitStatus('success');
});
})
.catch(() => {
startTransition(() => {
setSubmitStatus('error');
});
});
};
return (
<form onSubmit={handleSubmit} className="checkout-form">
<input
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Email"
disabled={isPending && submitStatus === 'idle'} // optional: soften UX
/>
<input
name="cardNumber"
value={formData.cardNumber}
onChange={handleChange}
placeholder="Card number"
/>
<input
name="expiry"
value={formData.expiry}
onChange={handleChange}
placeholder="MM/YY"
/>
<button
type="submit"
disabled={isPending}
className={`submit-btn ${isPending ? 'pending' : ''}`}
>
{isPending ? 'Processing...' : 'Pay Now'}
</button>
{/* Non-blocking status banner */}
{submitStatus === 'success' && (
<div className="status-banner success">✅ Payment confirmed!</div>
)}
{submitStatus === 'error' && (
<div className="status-banner error">⚠️ Try again — network issue</div>
)}
</form>
);
}
// Mock API
async function submitPayment(data: any): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 1200));
}
export default CheckoutForm;Key insights:
startTransitionwraps only thesetSubmitStatuscalls—not thefetchorsetTimeout. That’s intentional: network latency is outside React’s control; what we can control is how React schedules the UI response.- The submit button is disabled only during the transition (
isPending)—not during the entire async flow. That means if the user clicks “Pay Now”, then quickly edits the email field, the edit will go through immediately, even while the payment promise is pending. - The status banner appears without disrupting layout shifts because it’s rendered in the same commit as other pending updates—or deferred until the transition completes.
When Not to Use useTransition()
useTransition() shines for UI-driven async flows, but avoid it for:
- Critical loading states where users must wait (e.g., auth redirects, route-level data requirements). Use
Suspense+lazyinstead. - Operations that require synchronous side effects (e.g., analytics logging at the exact moment of submission).
startTransitiondoesn’t guarantee timing—just scheduling priority. - Simple local state toggles (e.g., dark mode). Those are already fast—no need to defer.
Also remember: isPending reflects React’s render scheduling, not network status. To track actual loading, combine it with your own isLoading state if needed—but often, isPending is sufficient for perception.
Bonus: Combining with useDeferredValue
For extra polish, pair useTransition() with useDeferredValue to smooth expensive renders (e.g., filtering large lists). While useTransition defers state updates, useDeferredValue defers rendering of a value:
const deferredQuery = useDeferredValue(query);
const filteredItems = useMemo(() =>
items.filter(item => item.name.toLowerCase().includes(deferredQuery.toLowerCase())),
[items, deferredQuery]
);This ensures list filtering doesn’t block typing—even if items is huge.
Final Thought: Performance Is a Feeling
useTransition() isn’t about shaving milliseconds off bundle size. It’s about aligning React’s rendering model with human attention: urgent actions (typing, clicking) get priority; background work (fetching, saving) gets grace.
You don’t need spinners to signal work—you need intentionality. Dim a button. Show a skeleton. Keep the cursor responsive. Let users feel in control.
That’s perceived performance. And with useTransition, it’s built in.
Start small: pick one form or search component. Replace isLoading with isPending. Observe how much more alive your UI feels—not faster, but kinder.


