useTransition Hook
Mark state updates as non-urgent so they don't block user input.
Recipe
Quick-reference recipe card — copy-paste ready.
const [isPending, startTransition] = useTransition();
// Wrap a slow state update
startTransition(() => {
setSearchResults(filterLargeList(query));
});
// Show pending state
{isPending && <Spinner />}When to reach for this: A state update causes expensive re-rendering (filtering a large list, switching tabs with heavy content) and you want the UI to stay responsive during the update.
Working Example
"use client";
import { useState, useTransition } from "react";
const ALL_ITEMS = Array.from({ length: 10000 }, (_, i) => ({
id: i,
name: `Item ${i}`,
category: ["Electronics", "Books", "Clothing", "Food"][i % 4],
}));
export function FilterableList() {
const [query, setQuery] = useState("");
const [filtered, setFiltered] = useState(ALL_ITEMS);
const [isPending, startTransition] = useTransition();
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value); // Urgent: update the input immediately
startTransition(() => {
// Non-urgent: filter the large list
setFiltered(
ALL_ITEMS.filter((item) =>
item.name.toLowerCase().includes(value.toLowerCase())
)
);
});
}
return (
<div className="space-y-3">
<input
value={query}
onChange={handleChange}
placeholder="Search 10,000 items..."
className="border rounded px-3 py-2 w-full"
/>
{isPending && <p className="text-sm text-gray-500">Updating...</p>}
<ul className="max-h-64 overflow-y-auto text-sm">
{filtered.slice(0, 100).map((item) => (
<li key={item.id} className="py-0.5">
{item.name} — {item.category}
</li>
))}
</ul>
<p className="text-xs text-gray-400">{filtered.length} results</p>
</div>
);
}What this demonstrates:
- Typing in the input is instant because
setQueryis outsidestartTransition - The expensive filtering runs in a transition, so React can interrupt it if the user types again
isPendingshows a loading indicator while the transition is in progress- The UI never freezes, even with 10,000 items
Deep Dive
How It Works
startTransitiontells React that the state update inside is non-urgent- React starts rendering the new state but can interrupt it if higher-priority updates come in (like typing)
- While the transition is pending,
isPendingistrue, letting you show a loading indicator - Transitions integrate with Suspense — if a component inside the transition suspends, React shows the old content instead of a fallback
- In React 19,
startTransitioncan also handle async functions, making it useful for server actions
Parameters & Return Values
| Parameter | Type | Description |
|---|---|---|
| (none) | — | useTransition takes no parameters |
| Return | Type | Description |
|---|---|---|
isPending | boolean | true while the transition is in progress |
startTransition | (callback: () => void) => void | Wraps state updates to mark them as non-urgent |
Variations
Tab switching with Suspense:
const [tab, setTab] = useState("home");
const [isPending, startTransition] = useTransition();
function selectTab(nextTab: string) {
startTransition(() => {
setTab(nextTab);
});
}
return (
<div>
<nav className={isPending ? "opacity-50" : ""}>
<button onClick={() => selectTab("home")}>Home</button>
<button onClick={() => selectTab("posts")}>Posts</button>
</nav>
<Suspense fallback={<Spinner />}>
{tab === "home" ? <Home /> : <Posts />}
</Suspense>
</div>
);React 19 async transitions with server actions:
const [isPending, startTransition] = useTransition();
function handleSubmit() {
startTransition(async () => {
const result = await saveToServer(formData);
setData(result); // UI updates after the server responds
});
}TypeScript Notes
// isPending is always boolean, startTransition accepts () => void
const [isPending, startTransition] = useTransition();
// React 19: startTransition also accepts async functions
startTransition(async () => {
await serverAction();
});Gotchas
-
Wrapping synchronous cheap updates — Using
startTransitionfor a simplesetCount(count + 1)adds overhead without benefit. Fix: Only use transitions for updates that cause expensive re-renders. -
Not splitting urgent from non-urgent — Wrapping both the input state and the filter state in
startTransitiondelays the input too. Fix: Keep urgent updates (input value) outsidestartTransition. -
isPending stays true too long — If the transition causes a Suspense boundary to suspend,
isPendingremains true until the suspended content resolves. Fix: This is expected behavior; design your loading states accordingly. -
startTransition must be synchronous (React 18) — In React 18, the callback must call
setStatesynchronously, not inside asetTimeoutor after anawait. Fix: In React 18, trigger the state update synchronously. In React 19, async callbacks are supported. -
Cannot wrap non-React state — Transitions only work with React state updates (
useState,useReducer). Updating a ref or external store insidestartTransitionhas no effect. Fix: Ensure the state update is a React state setter.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
useDeferredValue | You want to defer a specific value without controlling when the update fires | You need explicit control over which updates are non-urgent |
| Debouncing | Reducing the frequency of expensive operations (e.g., API calls) | You want React to remain responsive during the render itself |
| Web Worker | The computation is CPU-heavy and should not block the main thread at all | The work is rendering React components |
Virtualization (react-window) | Rendering thousands of DOM elements | The bottleneck is computation, not DOM nodes |
Transitions vs. debouncing: Debouncing delays the update entirely. Transitions let React start rendering immediately but interrupt if something more urgent arrives. Transitions keep the old UI visible while rendering the new one.
FAQs
What is the difference between useTransition and useDeferredValue?
useTransitionwraps the state update itself, giving you explicit control over which updates are non-urgent.useDeferredValuewraps the value consumption, deferring when a child re-renders with the new value.- Use
useTransitionwhen you control the state update; useuseDeferredValuewhen you receive the value as a prop.
Why should I keep the input state update outside startTransition?
- Wrapping the input value update in
startTransitiondelays the input from reflecting the user's typing. - Only the expensive downstream update (filtering, rendering heavy content) should be in the transition.
- Splitting urgent (input) from non-urgent (results) keeps the UI responsive.
How does isPending differ from a manually managed loading state?
isPendingis automatically set totruewhen the transition starts andfalsewhen it completes.- You don't need
setLoading(true)/setLoading(false)boilerplate. - It also integrates with Suspense boundaries --
isPendingstays true while suspended content loads.
Gotcha: Can I use startTransition with non-React state (refs, external stores)?
- No. Transitions only work with React state updates (
useState,useReducersetters). - Updating a ref or external store inside
startTransitionhas no effect. - The state update must call a React state setter to be recognized as a transition.
Can startTransition accept an async function?
- In React 19, yes.
startTransition(async () => { await serverAction(); setState(result); })is supported. - In React 18, the callback must call
setStatesynchronously -- noawaitinside. - Async transitions in React 19 make server actions seamless.
How does useTransition interact with Suspense?
- When a transition causes a component to suspend, React shows the old content instead of the Suspense fallback.
isPendingremainstrueuntil the suspended content resolves.- This prevents the UI from flashing loading states during tab switches or route changes.
Gotcha: Does wrapping a cheap state update in startTransition improve performance?
- No. For simple updates like
setCount(count + 1),startTransitionadds overhead without benefit. - Transitions are designed for updates that cause expensive re-renders (large lists, heavy components).
- Only use transitions when you can measure a responsiveness problem.
How do I type useTransition in TypeScript?
const [isPending, startTransition] = useTransition();
// isPending: boolean
// startTransition: (callback: () => void) => void
// React 19 also accepts async:
// startTransition: (callback: () => void | Promise<void>) => voidHow is useTransition different from debouncing?
- Debouncing delays the update entirely -- the user waits before anything happens.
- Transitions let React start rendering immediately but interrupt if something more urgent arrives.
- Transitions keep the old UI visible while rendering the new one, providing a smoother experience.
Can I nest multiple startTransition calls?
- Multiple
startTransitioncalls in the same handler are batched into a single transition. - React treats all state updates inside
startTransitionas low-priority together. - There is no concept of transition priority levels -- all transitions are equally non-urgent.
Related
- useDeferredValue — defer a value instead of wrapping an update
- useState — state updates wrapped by transitions
- useActionState — React 19 form actions use transitions internally
- useOptimistic — show optimistic state during transitions