Lazy Search Without Debounce
Debouncing with setTimeout is a pragmatic crutch, not a solution. useDeferredValue from React 18 defers expensive render work without a fixed time window, so the browser stays responsive while the result list updates in the background.
Table of Contents
- 1. The Problem With Debounce in React
- 2. Concurrent Mode: How React 18 Assigns Priorities
- 3. useDeferredValue: Syntax and Basic Principle
- 4. Building a Lazy Search Step by Step
- 5. React.memo as a Required Partner
- 6. Stale-State Feedback: Showing a Loading State
- 7. useDeferredValue vs. Debounce: A Direct Comparison
- 8. Common Pitfalls and How to Avoid Them
- 9. TypeScript Integration and Type Safety
- 10. Summary
- 11. FAQ
1. The Problem With Debounce in React
Anyone implementing a real-time search in React instinctively reaches for debounce: a useEffect with setTimeout delays the expensive filter pass by 300 milliseconds once the user stops typing. That works, but it has fundamental weaknesses. A fixed time window is a heuristic; on a fast desktop machine 300 ms is too long, on a cheap mobile device it is too short. The result is either a noticeable flicker or, worse, a sluggish UI that responds to keystrokes only after a delay.
The second problem lies in the React integration itself. A debounce timer lives outside the render cycle. It is managed inside a useEffect with a cleanup function, which leads to subtle bugs: if the user types quickly, several timers can be active at once. If the component unmounts before the timer fires and the cleanup does not run correctly, state updates get applied to an already unmounted component, a common source of memory-leak warnings. useDeferredValue solves exactly this problem through native browser concurrency instead of arbitrary timers.
The third aspect concerns the user experience under load. When the JavaScript bundle is large and the main thread is busy, a 300 ms debounce can extend the blocking instead of shortening it, since the timer only starts once the main thread is free again. With Concurrent Mode and useDeferredValue, React prioritizes the input handler and defers expensive render work, so the UI stays responsive under load.
2. Concurrent Mode: How React 18 Assigns Priorities
React 18 introduces a fundamentally new scheduling model with Concurrent Mode. Instead of rendering synchronously from top to bottom, React 18 can interrupt render work, defer it, and process it in smaller units. The key concept here is rendering as interruptible work: if a more urgent update, say a keystroke, arrives while React is rendering an expensive list, React can pause the running work, process the urgent update, and then continue painting the list.
This scheduling is based on priority lanes. React distinguishes between synchronous updates (user input such as clicks and keystrokes) and transitions (updates that represent the result of an input but do not need to be visible immediately). useDeferredValue and startTransition are the two APIs developers use to tell the scheduler which work is allowed to be deferred. The difference: startTransition marks a state-update function as low priority, while useDeferredValue marks an already existing value as "allowed to be stale".
Concretely, for a search component this means: the input state (query) is always updated synchronously so the user sees what they type immediately. The derived value deferredQuery, used for the expensive filter pass, lags behind, but not by a fixed amount of time; rather for as long as the browser needs to process urgent work. On fast devices the delay is close to zero, on slow ones it scales appropriately.
3. useDeferredValue: Syntax and Basic Principle
useDeferredValue has a very simple interface: the hook takes a value and returns a deferred version of that value. In React 18, the second argument does not exist yet; from React 19 an initial fallback value can be passed. The core principle: React first renders the component with the old value (the "stale" value) to show the UI immediately, then schedules a re-render in the background with the new value. If another update arrives while this background render is running, the running background render is discarded and restarted.
Important: useDeferredValue only helps when the component consuming the value is actually expensive to render. If the rendering is trivial, the hook only adds unnecessary complexity. The hook is not a magic performance switch; it is a tool that explicitly marks expensive renders as low priority and allows React to give urgent work precedence. Combined with React.memo, it prevents the child component from re-rendering on every keystroke while the deferred value is still the same.
import { useState, useDeferredValue, memo, useMemo } from 'react';
// Expensive list component, only re-renders when deferredQuery changes
const SearchResults = memo(function SearchResults({
query,
items,
}: {
query: string;
items: string[];
}) {
// Simulate expensive filtering operation
const results = useMemo(() => {
if (!query) return items;
return items.filter((item) =>
item.toLowerCase().includes(query.toLowerCase())
);
}, [query, items]);
return (
<ul>
{results.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
});
export function LazySearch({ items }: { items: string[] }) {
const [query, setQuery] = useState('');
// deferredQuery lags behind query, React prioritises the input update
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{/* Visual feedback while deferred value catches up */}
<div style={{ opacity: isStale ? 0.5 : 1, transition: 'opacity 0.2s' }}>
<SearchResults query={deferredQuery} items={items} />
</div>
</div>
);
}
4. Building a Lazy Search Step by Step
A complete lazy search implementation consists of three building blocks: the input state, the deferred value, and the memoized result component. The input state is managed with useState and is the only state that is updated synchronously, so the user gets immediate visual feedback. The deferred value is produced by useDeferredValue and lags behind. The result component is wrapped in React.memo and receives only the deferred value, never the current query state.
In practice you often manage not just a search term but also filter parameters. The pattern stays identical: all parameters that trigger an expensive filter pass are bundled into an object, that object is passed to useDeferredValue, and the result component receives only the deferred value. React compares objects by reference here, so a new object reference on every render always triggers a re-render of the child component. That is why the filter object must be stabilized with useMemo before being passed to useDeferredValue.
A detail that is often overlooked: useDeferredValue is only effective when the child component can actually skip rendering if the value has not changed. That requires React.memo on the child component. Without React.memo, React re-renders the child component on every render of the parent component, and the deferred value has no effect on performance.
5. React.memo as a Required Partner
React.memo is the prerequisite for useDeferredValue to have any effect. The hook itself only defers the value; whether that actually results in less rendering depends on whether the child component skips rendering as long as its props are unchanged. That is exactly what React.memo does: it shallowly compares old and new props and prevents the re-render when no prop has changed.
The default comparison of React.memo is a shallow equality check using Object.is. That means primitive values like strings and numbers are compared reliably, but objects and arrays only by reference. Anyone passing a complex filter object as a prop must ensure the reference is stable, via useMemo in the parent component. Alternatively, you can give React.memo a custom comparison function that performs a deep comparison, but that is rarely necessary and can itself introduce overhead.
import { useState, useDeferredValue, memo, useMemo } from 'react';
interface FilterParams {
query: string;
category: string;
minPrice: number;
}
interface Product {
id: number;
name: string;
category: string;
price: number;
}
// Wrapped with memo, only re-renders when filter reference changes
const ProductList = memo(
function ProductList({
filter,
products,
}: {
filter: FilterParams;
products: Product[];
}) {
const results = useMemo(() => {
return products.filter(
(p) =>
p.name.toLowerCase().includes(filter.query.toLowerCase()) &&
(filter.category === '' || p.category === filter.category) &&
p.price >= filter.minPrice
);
}, [filter, products]);
return (
<ul>
{results.map((p) => (
<li key={p.id}>
{p.name} / {p.category} / {p.price}$
</li>
))}
</ul>
);
}
);
export function ProductSearch({ products }: { products: Product[] }) {
const [query, setQuery] = useState('');
const [category, setCategory] = useState('');
const [minPrice, setMinPrice] = useState(0);
// Stable object reference, only changes when params change
const filter = useMemo(
() => ({ query, category, minPrice }),
[query, category, minPrice]
);
// Deferred filter, React may render with old filter first
const deferredFilter = useDeferredValue(filter);
const isStale = filter !== deferredFilter;
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Product name..." />
<input value={category} onChange={(e) => setCategory(e.target.value)} placeholder="Category..." />
<input type="number" value={minPrice} onChange={(e) => setMinPrice(Number(e.target.value))} />
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<ProductList filter={deferredFilter} products={products} />
</div>
</div>
);
}
6. Stale-State Feedback: Showing a Loading State
Whenever useDeferredValue is used, there is always a moment where the displayed value does not match the current input. This moment is the "stale" state. React does not provide a built-in API to detect this state, but it can be derived trivially: if the current query state and the deferred value are not identical, the content is stale. A simple opacity reduction with a CSS transition conveys this feedback to the user without an elaborate loading spinner.
For more refined feedback, you can combine useTransition, which returns an explicit isPending boolean. The difference: useTransition is used at the call site of the state update and marks the update itself as a transition. useDeferredValue, in contrast, is applied to the value itself and makes sense when you need to react to a value you do not control yourself, for example a prop coming from a parent component. The comparison query !== deferredQuery is a reliable way to detect the stale state in both cases.
In more complex applications, it is worth making the stale state perceivable to screen reader users too. An aria-busy="true" on the result container, set for as long as the stale state is active, informs assistive technologies that the content is currently being updated. That is a simple but important addition for accessible search experiences.
7. useDeferredValue vs. Debounce: A Direct Comparison
The choice between useDeferredValue and debounce depends on context. Debounce is a fixed time window; it works deterministically and is easy to understand. It works well for network requests because there you explicitly want to avoid firing too many of them. For expensive client-side render operations it is a poor choice, because it neither interacts with the React render pipeline nor adapts to device performance.
| Criterion | Debounce (setTimeout) | useDeferredValue |
|---|---|---|
| Time window | Fixed (e.g. 300 ms) | Adaptive, depending on browser load |
| React integration | Outside the render cycle | Concurrent Mode native |
| Memory leaks | Manual cleanup required | No cleanup, React manages everything |
| Throttling network requests | Ideal | Not suitable (no request throttling) |
| Expensive client renders | Suboptimal | Ideal combined with React.memo |
In practice, both approaches are combined: useDeferredValue for the render part (the expensive result list), and debounce for the network request (the autocomplete API). That produces the optimal user experience: immediate input feedback, deferred but responsive client-side rendering, and controlled API calls.
8. Common Pitfalls and How to Avoid Them
The most common mistake when using useDeferredValue is missing React.memo on the child component. Without memoization, React re-renders the child component on every render of the parent component, and the deferred value has no effect at all. The hook only defers the value, not the rendering. The rendering itself must be prevented by React.memo (or useMemo for expensive calculations).
A second common pitfall: the object passed to useDeferredValue is recreated on every render. Since React compares objects by reference, the hook sees a "new" object on every render and schedules a re-render, even if the values have not changed. The solution is useMemo to stabilize the object. Primitive values (strings, numbers, booleans) do not have this problem; they are compared by value.
A third pitfall: useDeferredValue does not help with network requests. The hook defers render work but does not influence when requests are triggered. If a useEffect watches the query state and fires a request on every change, useDeferredValue will not prevent that. For request throttling, debounce or a dedicated throttling pattern (e.g. via AbortController) is the right choice.
import { useState, useDeferredValue, memo, useMemo, useEffect } from 'react';
// WRONG: object created on every render, useDeferredValue sees a new reference
// each time and always schedules a re-render
function BadExample({ query }: { query: string }) {
// New object reference on every render!
const filter = { query, active: true };
const deferredFilter = useDeferredValue(filter); // Always "new"
// ...
}
// RIGHT: stabilise object with useMemo before passing to useDeferredValue
function GoodExample({ query }: { query: string }) {
const filter = useMemo(() => ({ query, active: true }), [query]);
const deferredFilter = useDeferredValue(filter); // Stable reference
// ...
}
// RIGHT: combine useDeferredValue for rendering plus debounce for network
function SearchWithAPI({ items }: { items: string[] }) {
const [inputValue, setInputValue] = useState('');
const [apiQuery, setApiQuery] = useState(''); // debounced for API
const deferredInput = useDeferredValue(inputValue); // deferred for rendering
// Debounce only the API call, not the render
useEffect(() => {
const timer = setTimeout(() => setApiQuery(inputValue), 400);
return () => clearTimeout(timer);
}, [inputValue]);
// Use deferredInput for expensive client-side filtering
const clientResults = useMemo(
() => items.filter((i) => i.includes(deferredInput)),
[deferredInput, items]
);
return <div>{/* render clientResults */}</div>;
}
9. TypeScript Integration and Type Safety
useDeferredValue is fully covered by the React type definitions. The hook is generic and automatically derives the return type from the value passed in, no manual type annotation needed. In TypeScript projects, it is a good idea to define the filter parameters as an interface or type alias and consistently use that type for the state, the useMemo value, and the child component props. That ensures the type is updated consistently everywhere when new filter parameters are added.
A common TypeScript mistake when using React.memo with generic components: the type of the memoized component loses its genericity. This can be worked around by typing the component first and then wrapping it separately with React.memo, instead of doing both in one expression. Alternatively, you can annotate the component as an arrow function with an explicit type. The TypeScript type definitions for useDeferredValue from React 19 onward include the optional second parameter (initialValue); in React 18 projects, passing a second argument results in a type error.
A good approach for type-safe search is to use a discriminated union type for the search state: { status: 'idle' } | { status: 'searching'; query: string } | { status: 'results'; results: T[] }. This approach makes the stale state explicitly type-safe and prevents accessing results when the status is idle. Combined with useDeferredValue, this results in a robust, type-safe search architecture.
10. Summary
useDeferredValue is the right tool when expensive client-side render operations impair UI responsiveness. The hook defers a value so React first processes urgent updates (keyboard input, clicks) and then re-renders in the background with the updated value. The result is a UI that stays responsive under load, without fixed timers, without memory-leak risk, and without bypassing the React render cycle.
The three prerequisites for effective use: first, the child component must be wrapped in React.memo so rendering can be skipped while the deferred value has not yet been updated. Second, objects passed to useDeferredValue must be stabilized with useMemo. Third, useDeferredValue is not a replacement for debounce with network requests; both approaches serve different purposes and can be combined. Anyone who keeps these three points in mind can fully replace debounce heuristics in React applications with native Concurrent Mode scheduling.
useDeferredValue: The Essentials at a Glance
Basic principle
Defers a value so React processes urgent updates (input) first and schedules expensive renders in the background.
Required partner: React.memo
Without React.memo on the child component, useDeferredValue has no effect, since rendering is not skipped.
Stabilize objects
Always stabilize objects with useMemo before passing them to useDeferredValue, otherwise the hook reacts to every new reference.
No API throttling
useDeferredValue does not throttle network requests. Use debounce for API calls, useDeferredValue for expensive client renders.