useTransition vs useDeferredValue: Side by Side
useTransition vs useDeferredValue: Side by Side#
React 18 shipped in late March. I've had useTransition in production for about six weeks now, and useDeferredValue for three. This post is the practical companion — same UIs shown with and without these hooks, so the difference is obvious.
One thing upfront: these hooks don't make rendering faster. They make the UI feel faster by telling React "this update can wait, keep the input responsive." The total work is the same. The perceived responsiveness is different.
useTransition: Search Filtering#
Here's a transaction search. Without useTransition, typing into the search box while filtering 2,000+ rows feels sluggish — every keystroke triggers a full list re-render before the next character can appear in the input.
Without useTransition:
// The naive version — input lags on every keystroke
export function TransactionList({ transactions }: { transactions: Transaction[] }) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
if (!query) return transactions;
const lower = query.toLowerCase();
return transactions.filter(
(t) =>
t.description.toLowerCase().includes(lower) ||
t.category.toLowerCase().includes(lower)
);
}, [transactions, query]);
return (
<div>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search transactions..."
/>
<p>{filtered.length} of {transactions.length} transactions</p>
{filtered.map((t) => (
<TransactionRow key={t.id} transaction={t} />
))}
</div>
);
}
With useTransition:
import { useTransition, useState, useMemo } from "react";
interface Transaction {
id: string;
description: string;
amount: number;
category: string;
date: string;
}
export function TransactionList({ transactions }: { transactions: Transaction[] }) {
const [isPending, startTransition] = useTransition();
const [query, setQuery] = useState("");
const [activeQuery, setActiveQuery] = useState("");
function handleSearch(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value); // urgent: input updates immediately
startTransition(() => {
setActiveQuery(value); // non-urgent: filter can wait
});
}
const filtered = useMemo(() => {
if (!activeQuery) return transactions;
const lower = activeQuery.toLowerCase();
return transactions.filter(
(t) =>
t.description.toLowerCase().includes(lower) ||
t.category.toLowerCase().includes(lower)
);
}, [transactions, activeQuery]);
return (
<div>
<div className="search-bar">
<input
type="search"
value={query}
onChange={handleSearch}
placeholder="Search transactions..."
/>
{isPending && <span className="spinner" aria-label="Filtering..." />}
</div>
<div style={{ opacity: isPending ? 0.7 : 1, transition: "opacity 150ms" }}>
<p className="result-count">
{filtered.length} of {transactions.length} transactions
</p>
{filtered.map((t) => (
<TransactionRow key={t.id} transaction={t} />
))}
</div>
</div>
);
}
The trick is two state values: query for the input (updates synchronously, no lag) and activeQuery for the filter (updates inside the transition, can be interrupted). If the user keeps typing, React drops the in-progress filter render and starts a new one.
The input stays crisp. The list catches up.
useTransition: Tab Switching#
The other place I've been using it constantly — tabs where one panel is expensive to render:
Without useTransition:
// Clicking "Analytics" freezes the UI for ~200ms while charts render
export function AccountDashboard({ accountId }: { accountId: string }) {
const [activeTab, setActiveTab] = useState<"overview" | "transactions" | "analytics">("overview");
return (
<div>
<nav>
<button onClick={() => setActiveTab("overview")}>Overview</button>
<button onClick={() => setActiveTab("transactions")}>Transactions</button>
<button onClick={() => setActiveTab("analytics")}>Analytics</button>
</nav>
{activeTab === "overview" && <AccountOverview accountId={accountId} />}
{activeTab === "transactions" && <TransactionHistory accountId={accountId} />}
{activeTab === "analytics" && <SpendingAnalytics accountId={accountId} />}
</div>
);
}
With useTransition:
type TabId = "overview" | "transactions" | "analytics";
export function AccountDashboard({ accountId }: { accountId: string }) {
const [isPending, startTransition] = useTransition();
const [activeTab, setActiveTab] = useState<TabId>("overview");
function handleTabChange(tab: TabId) {
startTransition(() => {
setActiveTab(tab);
});
}
return (
<div>
<nav className="tab-nav" aria-label="Account sections">
{(["overview", "transactions", "analytics"] as TabId[]).map((tab) => (
<button
key={tab}
onClick={() => handleTabChange(tab)}
aria-selected={activeTab === tab}
aria-current={activeTab === tab ? "page" : undefined}
className={activeTab === tab ? "tab tab--active" : "tab"}
disabled={isPending}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</nav>
<div style={{ opacity: isPending ? 0.5 : 1 }}>
{activeTab === "overview" && <AccountOverview accountId={accountId} />}
{activeTab === "transactions" && <TransactionHistory accountId={accountId} />}
{activeTab === "analytics" && <SpendingAnalytics accountId={accountId} />}
</div>
</div>
);
}
The click registers instantly. The old tab dims (isPending). The new tab renders in the background. If SpendingAnalytics takes 200ms to render with all its charts, the user never notices because the UI stays responsive.
This pattern alone justified adopting React 18 at work.
useDeferredValue: When You Don't Own the State#
useDeferredValue solves the same problem from a different angle. Use it when the expensive render is triggered by a prop you receive — you can't wrap the parent's setState in startTransition because you don't control it.
import { useDeferredValue, useMemo } from "react";
interface ProductGridProps {
products: Product[];
searchQuery: string; // comes from parent, you don't control it
}
export function ProductGrid({ products, searchQuery }: ProductGridProps) {
const deferredQuery = useDeferredValue(searchQuery);
const isStale = deferredQuery !== searchQuery;
const filtered = useMemo(() => {
return products.filter((p) => {
const q = deferredQuery.toLowerCase();
return p.name.toLowerCase().includes(q) || p.sku.includes(q);
});
}, [products, deferredQuery]);
return (
<div
className="product-grid"
style={{ opacity: isStale ? 0.6 : 1 }}
aria-busy={isStale}
>
{filtered.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
);
}
deferredQuery lags behind searchQuery when React is busy. The isStale check gives you the same visual feedback that isPending gives with useTransition. No coordination with the parent needed.
Where useDeferredValue Shines: Library Components#
The real power is in reusable components where you can't touch the parent:
// You're consuming a component you don't own. The parent passes query as a prop.
// You want YOUR rendering to be non-urgent without changing the parent at all.
export function SearchResults({ query }: { query: string }) {
const deferredQuery = useDeferredValue(query);
const isStale = deferredQuery !== query;
const results = useSearchResults(deferredQuery); // custom hook
return (
<div style={{ opacity: isStale ? 0.7 : 1 }}>
{results.map((r) => (
<ResultItem key={r.id} result={r} />
))}
</div>
);
}
The parent doesn't know or care that SearchResults defers its rendering. Zero coordination.
Decision Guide#
| Situation | Use |
|---|---|
You control the setState that triggers the expensive render | useTransition |
| Expensive render is triggered by a prop you don't control | useDeferredValue |
| You need a loading/pending indicator | useTransition (gives you isPending) |
| Building a reusable component that should defer on its own | useDeferredValue |
| Need to cancel an in-flight render when new input arrives | Either — React handles this |
Short version: building a feature end-to-end? useTransition, because you own the state. Building a reusable component? useDeferredValue, because you don't.
What These Hooks Don't Do#
This matters. Neither hook:
- Makes your render function run faster
- Eliminates the need for
React.memoon expensive subtrees - Replaces debouncing for network requests
- Helps with layout thrashing
// This is still wrong — the computation blocks before the transition even starts
startTransition(() => {
setItems(computeExpensiveLayout(rawData)); // blocks right here
});
// The expensive work needs to happen INSIDE the render path
// or be moved to a Web Worker
If you render 5,000 items without virtualization, startTransition won't save you. You need virtualization. If your filter takes 2 seconds, startTransition keeps the input responsive during those 2 seconds, but the results are still 2 seconds away. The work still happens.
Combine with React.memo#
For transitions to be interruptible, React needs to bail out of renders partway through. That works best when you have React.memo boundaries:
// Memoized — unchanged rows skip re-render during the transition
const TransactionRow = React.memo(function TransactionRow({
transaction,
}: {
transaction: Transaction;
}) {
return (
<div className="transaction-row">
<span className="description">{transaction.description}</span>
<span className="amount" data-negative={transaction.amount < 0}>
{formatCurrency(transaction.amount)}
</span>
<span className="category">{transaction.category}</span>
<time dateTime={transaction.date}>{formatDate(transaction.date)}</time>
</div>
);
});
When the filter changes, only rows with changed props re-render. Combined with a transition, the render can be interrupted at any row boundary. This is where the two patterns reinforce each other.
Common Pitfalls#
Using startTransition for mutations. If the user submits a form, they're waiting for the result. Don't defer that. Transitions are for render work that computes a visual state — not network requests or side effects.
Not showing isPending feedback. Without visual feedback, transitions feel broken. The user clicked a tab and nothing happened for 200ms. Always dim the old content or show a spinner. This isn't optional UX — it's the whole point.
Expecting cancellation of async work. React cancels the in-progress render when a new transition starts. It does not cancel Promises or async functions you started inside the transition. Those run to completion.
// React cancels the render. The fetch? Still running.
startTransition(() => {
fetchAnalyticsData().then((data) => {
setAnalytics(data); // this setState may be abandoned
});
});
// Better: fetch outside the transition, put the result in a transition
Six weeks in, these two hooks have earned their spot. The filter case alone — keeping input snappy during expensive list renders — was worth the upgrade. But the mental shift is the real takeaway: concurrent React isn't about making things faster. It's about never blocking user input.