useTransition
useTransition lets you mark some state updates as non-urgent transitions, telling React it can interrupt and deprioritize the resulting render so urgent work — like typing into an input — stays smooth. It is one of React’s concurrent features: instead of blocking the main thread while a heavy component tree re-renders, React keeps the old UI interactive and swaps in the new one when it is ready. This matters because a single expensive update (filtering ten thousand rows, switching a data-heavy tab) can otherwise freeze every keystroke and click on the page.
The shape of the hook
useTransition takes no arguments and returns a tuple with two values:
import { useTransition } from "react";
function Component() {
const [isPending, startTransition] = useTransition();
// ...
}
| Returned value | Type | Purpose |
|---|---|---|
isPending | boolean | true while the transition is rendering in the background |
startTransition | (callback: () => void) => void | Wraps the state updates you want treated as non-urgent |
Any state update you call inside the startTransition callback is flagged as a transition. React renders that update at a lower priority, and if a more urgent update arrives (a new keystroke), React abandons the in-progress transition render and starts over with fresh data.
Keeping an input responsive while filtering
The classic problem: a search box that filters a large list. If filtering happens synchronously, every keystroke waits for the whole list to re-render, and the input lags. By splitting the urgent update (the input value) from the non-urgent update (the filtered list), the input updates instantly.
import { useState, useTransition } from "react";
// Pretend this is an expensive computation over a big dataset.
function buildItems() {
return Array.from({ length: 20000 }, (_, i) => `Item number ${i}`);
}
const ALL_ITEMS = buildItems();
export default function SearchableList() {
const [query, setQuery] = useState("");
const [results, setResults] = useState(ALL_ITEMS);
const [isPending, startTransition] = useTransition();
function handleChange(event) {
const next = event.target.value;
// Urgent: the input reflects the keystroke immediately.
setQuery(next);
// Non-urgent: filtering the large list can be interrupted.
startTransition(() => {
const filtered = ALL_ITEMS.filter((item) =>
item.toLowerCase().includes(next.toLowerCase())
);
setResults(filtered);
});
}
return (
<div>
<input value={query} onChange={handleChange} placeholder="Search..." />
{isPending && <p>Updating list...</p>}
<ul>
{results.slice(0, 50).map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
}
While you type fast, the <input> never stalls because its setQuery update is urgent. The setResults update runs as a transition, and isPending lets you show a subtle “Updating list…” hint without blocking interaction.
Output:
[input shows "ite" instantly as you type]
Updating list... <- briefly, while the 20k-item filter renders
- Item number 0
- Item number 1
... (first 50 matches)
Switching tabs without freezing
Transitions also shine when navigating between views where one tab mounts a heavy component. Mark the tab change as a transition so the click feels instant and the old tab stays on screen until the new one is ready.
import { useState, useTransition } from "react";
function TabButton({ label, isActive, isPending, onClick }) {
return (
<button onClick={onClick} disabled={isActive}>
{label}
{isActive && isPending ? " (loading)" : ""}
</button>
);
}
export default function Tabs() {
const [tab, setTab] = useState("home");
const [isPending, startTransition] = useTransition();
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
return (
<div style={{ opacity: isPending ? 0.6 : 1 }}>
<nav>
{["home", "reports", "settings"].map((name) => (
<TabButton
key={name}
label={name}
isActive={tab === name}
isPending={isPending}
onClick={() => selectTab(name)}
/>
))}
</nav>
{tab === "reports" ? <HeavyReports /> : <p>Viewing {tab}</p>}
</div>
);
}
function HeavyReports() {
const rows = Array.from({ length: 5000 }, (_, i) => i);
return (
<ul>
{rows.map((r) => (
<li key={r}>Report row {r}</li>
))}
</ul>
);
}
Because the tab swap is a transition, clicking “reports” does not lock the UI. The current tab stays visible (dimmed via isPending) until the heavy list finishes rendering.
What belongs inside startTransition
Only state updates triggered synchronously inside the callback are marked as transitions. The callback must run synchronously — React reads which updates happened during that tick.
startTransition(() => {
// ✅ marked as a transition
setResults(expensiveFilter(value));
});
startTransition(async () => {
// ⚠️ only updates before the first await are part of the transition
const data = await fetchData();
setResults(data); // NOT a transition unless wrapped again
});
Gotcha: You cannot use a transition to control a text input’s own value. The value backing a controlled input must update urgently, or React warns and the input will feel broken. Keep the input’s
setStateoutside the transition and only defer the derived/expensive work.
Tip: If you only need to defer a value rather than wrap an update, reach for
useDeferredValue— it is the same concurrency engine without needing access to the setter.
useTransition vs useDeferredValue
| Aspect | useTransition | useDeferredValue |
|---|---|---|
| You control | The state update (the setter call) | A value you already have |
| Gives you | isPending flag + startTransition | A lagging copy of the value |
| Best when | You own the update logic | The value comes from props/parent |
Best practices
- Keep urgent updates (input values, toggles, focus) outside
startTransition; only wrap the expensive, derived render work. - Use
isPendingfor lightweight feedback — dimming, a spinner, or a small label — not to unmount the current view. - Don’t wrap
awaited updates expecting them to stay transitions; updates after the firstawaitneed re-wrapping. - Avoid using transitions for controlled input values themselves; it desyncs what the user types.
- Reach for
useDeferredValueinstead when you receive a value rather than trigger the update. - Measure first: transitions help when a render is genuinely heavy. For cheap updates they add complexity with no payoff.