Keeping Inputs Snappy with React's useTransition: A Practical Guide
React's useTransition lets you mark expensive state updates as non-urgent so typing stays responsive. Here's the pattern, the pitfalls, and how to verify it works.
09 Mar 2026, 20:55 UTC

You've built a search box that filters a list of a few thousand items. Every keystroke triggers a re-render of the entire filtered list, and the input starts lagging behind the user's typing. The list itself renders fast enough in isolation — the problem is that React treats the keystroke and the expensive list update as one unit of work, so the input can't repaint until the list finishes.
React 18's useTransition hook addresses exactly this: it lets you mark the expensive update as non-urgent, so React can keep the urgent update (the text appearing in the input) responsive and interrupt the slow render if the user keeps typing. This article assumes React 18 or later; concurrent features don't exist in earlier versions.
Urgent vs. non-urgent updates
Concurrent rendering lets React pause, abandon, or restart a render in progress. useTransition is the main way you opt into that behavior from component code. It returns two things:
isPending— a boolean that's true while the transition is still being processed.startTransition— a function you wrap around the state update you consider non-urgent.
The key mental model: you're not making the slow render faster. You're telling React it's allowed to delay or discard that render in favor of more important work. If the user types three more characters while the list is filtering, React throws away the stale intermediate renders and only commits the latest result.
A worked example: filtering a large list
Here's the classic pattern — note the deliberate split into two state variables:
import { useState, useTransition } from 'react';
function SearchPage({ items }) {
const [query, setQuery] = useState(''); // urgent
const [filter, setFilter] = useState(''); // non-urgent
const [isPending, startTransition] = useTransition();
const results = items.filter(item =>
item.name.toLowerCase().includes(filter.toLowerCase())
);
function handleChange(e) {
const value = e.target.value;
setQuery(value); // updates the input immediately
startTransition(() => {
setFilter(value); // list re-render can be interrupted
});
}
return (
<>
{isPending && Updating results…}
{results.map(item => - {item.name}
)}
);
}The critical detail is that the input is controlled by query, which is updated outside the transition. If you wrapped the input's own state in startTransition, you'd reintroduce the lag — the text would appear a beat after each keystroke. Never transition the state that drives a controlled input.
What isPending is for (and what it isn't)
isPending flips to true as soon as the transition starts and back to false when React commits the result. Use it to communicate that the visible list is stale: dim it, show a small inline indicator, or disable a "sort" control until the update lands.
A common mistake is rendering a full-screen spinner for isPending. That defeats the purpose — the whole point is that the old UI stays interactive while the new one is prepared. Subtle, local feedback works better than blocking feedback.
Trade-offs and limits
- It doesn't speed anything up. The total CPU work is the same or slightly higher (React may render and discard intermediate states). If a single render of your list takes 500ms, transitions won't fix that — virtualize the list or memoize instead.
- Only worth it for genuinely expensive renders. For a list of 50 items, the scheduling overhead buys you nothing.
- Stale results are visible during the transition. If your UI can't tolerate briefly showing outdated data, a transition is the wrong tool.
- React 18+ only. On older versions the hook doesn't exist and updates are always synchronous.
How to verify it actually helps
Don't take the improvement on faith. Build the filter with a realistically large dataset (1,000+ items, or artificially slow down renders in development with CPU throttling in Chrome DevTools) and type quickly with and without the startTransition wrapper. Then open the React DevTools Profiler, record a typing session, and confirm the input commits separately from the list render. Finally, check that your pending indicator toggles — if isPending never appears, the render was probably too cheap to need a transition at all.
The actionable takeaway: split urgent state from expensive derived state, wrap only the expensive part in startTransition, and use isPending for subtle staleness feedback. It's a small code change with an outsized effect on perceived responsiveness — but only where a render is actually heavy enough to notice.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.