useTransition Hook
Mark state updates as non-urgent so they don't block user input.
Search across all documentation pages
Mark state updates as non-urgent so they don't block user input.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
"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:
setQuery is outside startTransitionisPending shows a loading indicator while the transition is in progressstartTransition tells React that the state update inside is non-urgentisPending is true, letting you show a loading indicatorstartTransition can also handle async functions, making it useful for server actions| 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 |
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
});
}// isPending is always boolean, startTransition accepts () => void
const [isPending, startTransition] = useTransition();
// React 19: startTransition also accepts async functions
startTransition(async () => {
await serverAction();
});Wrapping synchronous cheap updates - Using startTransition for a simple setCount(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 startTransition delays the input too. Fix: Keep urgent updates (input value) outside startTransition.
isPending stays true too long - If the transition causes a Suspense boundary to suspend, isPending remains 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 setState synchronously, not inside a setTimeout or after an await. 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 inside startTransition has no effect. Fix: Ensure the state update is a React state setter.
| 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.
useTransition wraps the state update itself, giving you explicit control over which updates are non-urgent.useDeferredValue wraps the value consumption, deferring when a child re-renders with the new value.useTransition when you control the state update; use useDeferredValue when you receive the value as a prop.startTransition delays the input from reflecting the user's typing.isPending is automatically set to true when the transition starts and false when it completes.setLoading(true) / setLoading(false) boilerplate.isPending stays true while suspended content loads.useState, useReducer setters).startTransition has no effect.startTransition(async () => { await serverAction(); setState(result); }) is supported.setState synchronously -- no await inside.isPending remains true until the suspended content resolves.setCount(count + 1), startTransition adds overhead without benefit.const [isPending, startTransition] = useTransition();
// isPending: boolean
// startTransition: (callback: () => void) => void
// React 19 also accepts async:
// startTransition: (callback: () => void | Promise<void>) => voidstartTransition calls in the same handler are batched into a single transition.startTransition as low-priority together.Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥