Keeping React Inputs Responsive with useTransition
Learn how React 18's useTransition hook separates urgent input updates from expensive list renders, keeping typing smooth on large datasets. Includes a concrete filterable-list example, trade‑offs, and a quick verification checklist.
22 Dec 2025, 07:38 UTC

The problem: typing lag on large lists
When a controlled input drives an expensive list render — think a 10 000‑row table filtered on every keystroke — the UI can feel sluggish. The state update that stores the query is urgent, but the downstream re‑render that filters and maps the data is not. React 18’s useTransition lets you separate those two priorities without rewriting the whole component tree.
How useTransition works
- Urgent update – the
setQuerycall stays synchronous so the input reflects each character instantly. - Transitioned update – the expensive filter is wrapped in
startTransition. React marks that render as low‑priority, keeps the current UI interactive, and exposesisPendingfor a subtle loading indicator. - Interruptibility – if the user types again before the low‑priority render finishes, React aborts the stale work and starts a new transition. The list never shows results for an outdated query.
Note: useTransition does not make rendering faster; total work is the same or slightly higher. The win is perceived responsiveness.
Worked example: filterable list
import { useState, useTransition, useMemo } from 'react';
function SearchableList({ data }) {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
// Urgent: keep the input snappy
const handleChange = (e) => {
const value = e.target.value;
setQuery(value); // urgent update
startTransition(() => {
setFilteredQuery(value); // transitioned update
});
};
// Separate state for the transitioned value
const [filteredQuery, setFilteredQuery] = useState('');
const filtered = useMemo(() =>
data.filter(item => item.name.toLowerCase().includes(filteredQuery.toLowerCase())),
[data, filteredQuery]
);
return (
<div>
<input value={query} onChange={handleChange} placeholder="Search…" />
{isPending && <span className="badge">Updating…</span>}
<ul>
{filtered.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
Key points in the snippet:
- Two pieces of state:
query(urgent) andfilteredQuery(transitioned). setQueryruns outsidestartTransition;setFilteredQueryruns inside.isPendingdrives a tiny “Updating…” badge — no full‑screen spinner.
Trade‑offs and limits
- Not a silver bullet – if the list render itself is O(n²) or creates massive DOM, you still need memoization, virtualization, or a smaller tree.
- Overhead for cheap updates – wrapping a trivial
setStatein a transition adds scheduler work with no visible benefit. isPendingscope – it only reflects the transition’s pending state, not async data fetching triggered elsewhere.- Suspense interaction – a transition that suspends keeps the old UI visible instead of showing a fallback, which can be surprising if you expect a spinner.
Quick verification checklist
- Create a demo (run in a terminal with Node ≥ 18):
Replacenpx create-react-app transition-demo --template cra-template cd transition-demo npm startsrc/App.jswith the component above and a 10 000‑item dataset. - Profile in React DevTools – open the Profiler, record a typing session, and verify:
- Urgent renders (input) stay < 5 ms.
- Transitioned renders appear as separate “lanes” and are interrupted on subsequent keystrokes.
- Test Suspense case – wrap the list in a
<Suspense fallback={<Spinner />}>and trigger a transition that calls a lazy‑loaded component. Confirm the old list stays visible until the new tree resolves. - Check React version – run
npm list reactand ensure you’re on 18.x or later; the API surface matches the description above.
If the profiler shows the urgent render growing, the bottleneck is likely in the input handler itself — move any heavy logic out of the urgent path.
Takeaway
Use useTransition when a single user action triggers both a cheap, must‑be‑instant update and an expensive, deferrable render. Keep the urgent setter outside the transition, let isPending drive a lightweight indicator, and verify with the profiler that the UI stays responsive. For values you don’t own, reach for useDeferredValue instead.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.