Using useTransition and useDeferredValue to Keep React Interfaces Responsive

Some React performance problems are not about raw speed so much as priority.
The application can finish the work eventually, but it insists on doing too much of it on the same path as a user's keystroke or click. That is when typing starts to feel sticky, tab switches feel oddly heavy, and the interface gives the impression that every update is equally urgent.
They are not.
That is where useTransition and useDeferredValue become useful. They help us tell React which updates need to feel immediate and which ones can lag slightly without hurting the experience.
Urgent and Non‑Urgent Updates are Not the Same Thing
If a user types into an input, the text box itself should update immediately. That part is urgent because it affects the direct feedback loop between the user's action and the screen.
The expensive search results list driven by that input may not be equally urgent. It can often afford to trail slightly behind whilst React keeps the input responsive.
That distinction is the whole point of these hooks.
useTransitionlets us mark a state update as lower priorityuseDeferredValuelets us keep reading an older value for part of the UI whilst the newer one catches up
They solve related problems, but they are not interchangeable.
useDeferredValue is excellent for expensive derived views
Search is the most obvious example.
Suppose a large results list is expensive to filter and render. This TypeScript example puts both jobs in a memoised child, leaving the controlled input in the parent:
import { memo, useDeferredValue, useState } from 'react';
import type { ReactElement } from 'react';
type Product = { id: string; name: string };
const ProductResults = memo(function ProductResults({ products, query }: {
products: Product[];
query: string;
}): ReactElement {
const search = query.toLowerCase();
const visibleProducts = products.filter(product =>
product.name.toLowerCase().includes(search)
);
return (
<ul>
{visibleProducts.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
});
const ProductSearch = ({ products }: { products: Product[] }): ReactElement => {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<>
<label>
Search products
<input value={query} onChange={event => setQuery(event.target.value)} />
</label>
<p role="status">{isStale ? 'Updating results...' : ''}</p>
<ProductResults products={products} query={deferredQuery} />
</>
);
};The input renders from query. During the urgent update, deferredQuery still has its earlier value, so memo can skip the unchanged ProductResults subtree. Keep the products array reference stable when its contents have not changed; recreating it on each input update would defeat that comparison.
That is often a cleaner fit than trying to throttle typing or invent a complicated local queue.
useTransition is better when the update itself is non‑urgent
With useTransition, we tell React that a particular state update should not block more urgent updates.
Here is a complete manual‑activation tab example in TypeScript. Pass the three panel contents through panels. Arrow keys, Home and End move focus; Enter, Space or a click selects the focused tab. Selection and the displayed panel change together:
import { useId, useRef, useState, useTransition } from 'react';
import type { KeyboardEvent, ReactElement, ReactNode } from 'react';
type TabId = 'overview' | 'activity' | 'settings';
const tabs: { id: TabId; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'activity', label: 'Activity' },
{ id: 'settings', label: 'Settings' },
];
const AccountTabs = ({ panels }: {
panels: Record<TabId, ReactNode>;
}): ReactElement => {
const id = useId();
const [activeTab, setActiveTab] = useState<TabId>('overview');
const [focusedIndex, setFocusedIndex] = useState(0);
const [focusWithin, setFocusWithin] = useState(false);
const buttons = useRef<(HTMLButtonElement | null)[]>([]);
const [isPending, startTransition] = useTransition();
const moveFocus = (event: KeyboardEvent<HTMLButtonElement>, index: number): void => {
let next: number;
if (event.key === 'ArrowRight') next = (index + 1) % tabs.length;
else if (event.key === 'ArrowLeft') next = (index + tabs.length - 1) % tabs.length;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = tabs.length - 1;
else return;
event.preventDefault();
setFocusedIndex(next);
buttons.current[next]?.focus();
};
return (
<>
<div
role="tablist"
aria-label="Account"
onFocus={() => setFocusWithin(true)}
onBlur={event => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setFocusWithin(false);
}
}}
>
{tabs.map((tab, index) => (
<button
key={tab.id}
type="button"
role="tab"
id={`${id}-tab-${tab.id}`}
aria-controls={`${id}-panel-${tab.id}`}
aria-selected={activeTab === tab.id}
tabIndex={(focusWithin ? focusedIndex === index : activeTab === tab.id) ? 0 : -1}
ref={button => { buttons.current[index] = button; }}
onFocus={() => setFocusedIndex(index)}
onKeyDown={event => moveFocus(event, index)}
onClick={() => startTransition(() => setActiveTab(tab.id))}
>
{activeTab === tab.id ? <strong>{tab.label}</strong> : tab.label}
</button>
))}
</div>
<p role="status">{isPending ? 'Updating tab...' : ''}</p>
<div aria-busy={isPending}>
{tabs.map(tab => (
<section
key={tab.id}
role="tabpanel"
id={`${id}-panel-${tab.id}`}
aria-labelledby={`${id}-tab-${tab.id}`}
hidden={activeTab !== tab.id}
tabIndex={0}
>
{activeTab === tab.id ? panels[tab.id] : null}
</section>
))}
</div>
</>
);
};If the new tab renders a heavy subtree, the transition tells React that keeping the rest of the interface responsive matters more than forcing the tab switch through at the same priority as an input event.
This becomes especially useful when the new state leads to Suspense or other expensive rendering work.
The Difference Between the Two Hooks Matters
This is the simplest way I think about them:
- use
useDeferredValuewhen one part of the UI can temporarily read an older value - use
useTransitionwhen a state update itself should be treated as lower priority
useDeferredValue is often a good fit for filtered lists, charts, or previews derived from fast‑changing input.
useTransition is often a good fit for tab changes, route‑like state, or non‑urgent panel updates that can show pending feedback.
If those roles still feel close together, that is normal. They are related tools. The deciding question is whether we want to defer a read or defer an update.
What These Hooks Do Not Do
They are useful, but they are not magic.
They do not:
- make a slow algorithm fast
- replace list virtualisation
- reduce bundle size
- fix a chatty network layer
- remove the need for good component boundaries
The synchronous filter() call still runs to completion once it starts; React cannot pause halfway through arbitrary JavaScript. Deferring the child lets React prioritise other renders around that work. If filtering or mapping a huge list is still too slow, improve the algorithm, use a worker where appropriate, or reduce the rendered list through virtualisation.
Do Not Transition the Controlled Input Itself
This is a common misuse because it seems intuitive to "make typing low priority". That is exactly backwards.
The input value should stay urgent:
const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {
setQuery(event.target.value);
};If we put the actual input state update inside startTransition, typing can begin to feel delayed or inconsistent. The responsive pattern is to keep the input urgent and move the heavy downstream work onto a lower‑priority path.
Pending UI Needs Restraint
The isPending flag is useful, but it should not become an excuse to cover the interface in spinners.
Often the best experience is subtle:
- keep the previous content visible
- show a light "updating" hint
- avoid blanking the whole panel unless that blanking is genuinely helpful
The point of these hooks is smoother continuity. If every transition replaces stable content with a full‑page loader, we lose much of the benefit.
They Work Best Alongside Sane Component Structure
These hooks are strongest when the expensive work is already isolated.
If one top‑level component owns every state change and rerenders the entire page for every interaction, useTransition and useDeferredValue can help, but they are doing more rescue work than they should have to.
Good state ownership, smaller subtrees, and measured expensive work still matter. These hooks are a refinement layer, not a substitute for architecture.
Useful References
useTransition and useDeferredValue are useful because they let us be more honest about user experience. Not every update deserves the same urgency. Typing into an input and recalculating a heavy preview are not the same job.
Once React is told that clearly, the interface often feels better without us having to build a lot of manual scheduling logic ourselves.