Keep Typing Smooth While React Renders a Heavy List with useTransition
React's useTransition hook separates urgent input updates from slow list re-renders. See a worked filtering example, trade-offs, and how to verify the behavior.
25 Sept 2025, 04:45 UTC

You type into a search box that filters a list of thousands of rows. Each keystroke stalls for a few hundred milliseconds. The input is controlled, so every character triggers a state update, and React renders the filtered list synchronously before the browser can paint the next character. The list is the bottleneck, but the user only cares about the text field.
The takeaway: React's useTransition hook lets you mark the list update as non-urgent. The input stays responsive at high priority while the heavy render happens in the background at a lower priority.
Urgent vs. non-urgent updates
By default, React treats state updates as equal. When you call setQuery and setFilteredItems in the same event handler, React schedules both for the same render pass. If filtering the list takes 200 ms, the input cannot display the new character until that work finishes.
Concurrent rendering, available in React 18 and later, introduces the idea of transitions: updates that can be interrupted, deferred, or even discarded if a more urgent update arrives. This does not make the render faster. It changes the order in which React does the work.
| Update type | Example | Priority | Interruptible? |
|---|---|---|---|
| Urgent | Typing in a controlled input | High | No |
| Transition | Filtering a large list | Lower | Yes |
How useTransition schedules work
The hook returns a pair: isPending and startTransition. You wrap slow state updates inside startTransition. React then renders that branch at a lower priority. While the transition is still rendering, isPending is true, which you can use to show subtle feedback like reduced opacity.
Two behaviors matter here. First, transitions are interruptible: if the user types again while the list is rendering, React can abandon the in-progress transition and start over with the newer value. Second, isPending only reflects whether the transition render is pending. It is not a network loading state.
Worked example: filtering a large dataset
Assume you have an array of items and a controlled search input. The urgent update is the input value; the non-urgent update is the filtered list.
import { useState, useTransition } from 'react';
function FilterableList({ items }) {
const [query, setQuery] = useState('');
const [filteredItems, setFilteredItems] = useState(items);
const [isPending, startTransition] = useTransition();
function handleChange(event) {
const nextQuery = event.target.value;
setQuery(nextQuery); // urgent: update the input immediately
startTransition(() => {
// non-urgent: React may interrupt or defer this
const nextItems = items.filter((item) =>
item.toLowerCase().includes(nextQuery.toLowerCase())
);
setFilteredItems(nextItems);
});
}
return (
<div>
<input value={query} onChange={handleChange} />
<ul style={{ opacity: isPending ? 0.6 : 1 }}>
{filteredItems.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}
If the filter computation itself is expensive, useTransition will not fix that. It only changes scheduling priority. Memoize the computation with useMemo or move it off the main thread to a Web Worker before reaching for the hook.
Trade-offs and limitations
- Do not wrap everything. Marking routine updates as transitions delays visual feedback and can make the app feel sluggish. Reserve it for genuinely slow renders.
- Side effects run immediately. Code inside
startTransitionexecutes synchronously. Move analytics, subscriptions, or other side effects touseEffect. - Stale results are possible. Because React can discard an in-progress transition, the list may briefly show results for an older query until the newer transition finishes.
- Version assumptions.
useTransitionwas introduced in React 18. Scheduling semantics may evolve, so confirm behavior against the React version in your project.
Verify it works
You can confirm the scheduling change without guessing:
- Open React DevTools, start the Profiler, and record while typing in the example above. The input update should appear as a separate, higher-priority commit from the list transition.
- Write a test that fires rapid
changeevents and asserts that the final rendered list matches the last input value, not an intermediate one. - Manually compare typing latency with and without
startTransitionusing an artificially slow list render, such as a large array with a blocking loop.
When a controlled input feels sticky during a heavy render, reach for useTransition before micro-optimizing the render itself. But profile first: if the computation is the real cost, memoization or a Web Worker will help more than reprioritizing the update.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.