Learn how to build a responsive async combobox component using React's useDeferredValue() and useSuspenseQuery(). Discover how these concurrent features work together to create smooth user experiences with declarative loading states, optimistic updates, and automatic caching for search interfaces.
Aurora Scharff
October 14, 2025
Originally published on aurorascharff.no
React concurrent features have unlocked new ways to build performant and responsive applications. In this blog post, I'll show you how to create a declarative combobox component using useDeferredValue()
and useSuspenseQuery()
. We'll explore how these hooks work together to deliver smooth user experiences, simplify loading and error state management, and provide automatic caching for optimal performance.
You might be familiar with useDeferredValue()
from React 18, which allows you to defer rendering a part of the UI.
It has a simple API:
const deferredValue = useDeferredValue(value);
Where value
is the value you want to defer, and deferredValue
is the deferred version of that value. It's a concurrent feature that tells React to prioritize urgent updates over less critical ones, keeping your application responsive.
The most common use of useDeferredValue()
is for rendering optimization. When you have expensive UI updates that might block user interactions, you can defer them to keep your app responsive. For example, if you have a search input that updates frequently, you can defer the rendering of a list until the browser has time to process it, reducing lag while typing.
Let's say you have an input field where users can type a search query, and a list of items that filters based on that query:
function SearchInput() {
const [query, setQuery] = useState('');
return (
<>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<List query={query} />
</>
);
}
The list component would filter items based on the query
state:
function List({ query }) {
const filteredItems = items.filter((item) =>
item.name.toLowerCase().includes(query.toLowerCase())
);
return (
<ul>
{filteredItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
If this list was really long, or it contained some heavy components, it could lead to performance issues as the user types. This is where useDeferredValue()
comes in handy.
You can wrap the query
state with useDeferredValue()
to defer the updates to the list:
function SearchInput() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<List query={deferredQuery} />
</>
);
}
Then, you would memoize the List
component to only re-render when the deferred query changes:
const List = React.memo(({ query }) => {
//...
});
It tells React that re-rendering the list can be deprioritized so that it doesn’t block the keystrokes. The list will “lag behind” the input and then “catch up”. Like before, React will attempt to update the list as soon as possible, but will not block the user from typing.
For a more detailed explanation of the rendering optimization aspect of useDeferredValue()
, check out the blog post Snappy UI Optimization with useDeferredValue() by Josh Comeau.
However, useDeferredValue()
can also be used with a suspense enabled data source to create a smooth stale-while-revalidate experience, which is what we will explore in this blog post.
A Suspense-enabled data source is any data-fetching mechanism that integrates with React's Suspense API. This includes:
lazy
use
When you use these approaches, React can suspend the component and show fallback UI (like loading spinners or skeleton UI) while waiting for the data to resolve, ensuring your components only render when the data is ready.
The React documentation frequently uses use()
with a simple cached promise as a suspense-enabled data source in its sandboxes to simplify data fetching.
TanStack Query can function as a suspense enabled data source, providing the useSuspenseQuery()
hook.
Here's a quick example of how you might use it:
import { useSuspenseQuery } from '@tanstack/react-query';
function SuspendedComponent() {
const { data } = useSuspenseQuery({
queryKey: ['dataKey'],
queryFn: fetchData
});
return <p>{data}</p>;
}
This hook fetches data and suspends the component until the data is available, allowing you to use Suspense fallbacks to show loading states:
import { Suspense } from 'react';
function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<SuspendedComponent />
</Suspense>
);
}
This is all the knowledge we need to build a combobox component that uses useDeferredValue()
and useSuspenseQuery()
together.
Let's start by looking at a simplified combobox component API:
<Combobox
asyncSearchFn={fetchData}
onSelect={handleSelect}
placeholder="Search data..."
/>
In a real combobox, you might want to add enhancements like support for static or default options, keyboard navigation, and accessibility improvements. And you might to build on top of a library like Ariakit. For this example, we'll keep things simple and focus on the essential async search pattern.
Here's our main component structure:
export default function Combobox({
asyncSearchFn,
onSelect,
placeholder = "Search...",
}) {
const [isOpen, setIsOpen] = useState(false);
const [filterText, setFilterText] = useState("");
const handleItemClick = (item) => {
setFilterText(item.name);
setIsOpen(false);
onSelect?.(item);
};
return (
<div>
<input
placeholder={placeholder}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
onFocus={() => setIsOpen(true)}
/>
{isOpen && (
<div className="combobox-container">
{/* Search results will go here */}
</div>
)}
</div>
);
}
The natural way to build this might be to keep a search result state, calling the asyncSearchFn
on every input change to fetch results, and handle loading and error states manually.
It could look something like this:
export default function BadCombobox({
asyncSearchFn,
onSelect,
placeholder = "Search...",
}) {
const [isOpen, setIsOpen] = useState(false);
const [filterText, setFilterText] = useState("");
const [searchResults, setSearchResults] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
const handleItemClick = (item) => {
setFilterText(item.name);
setIsOpen(false);
onSelect?.(item);
};
const handleSearch = async (text) => {
setIsLoading(true);
try {
const results = await asyncSearchFn(text);
setIsError(false);
setSearchResults(results);
} catch (error) {
setIsError(true);
} finally {
setIsLoading(false);
}
};
return (
<div>
<input
placeholder={placeholder}
value={filterText}
onChange={(e) => {
setFilterText(e.target.value);
if (e.target.value.length > 0) {
handleSearch(e.target.value);
} else {
setSearchResults([]);
setIsLoading(false);
setIsError(false);
}
}}
onFocus={() => setIsOpen(true)}
/>
{isOpen && (
<div className="combobox-container">
{isLoading ? (
<div>Loading...</div>
) : isError ? (
<div>Error loading results</div>
) : (
searchResults.map((item, index) => (
<div
key={item.id || index}
className="combobox-option"
onClick={() => handleItemClick(item)}
>
{item.name}
</div>
))
)}
</div>
)}
</div>
);
}
This quickly becomes cumbersome, and while searching we get an unstable and flickering UX in the dropdown list. This happens because we are not using Actions for our async function. However, rather messing around with more states to fix it, or using Actions, let's try something different.
Let's extract a SearchResults
component, which will use useSuspenseQuery()
to call the asyncSearchFn
with the current filterText
.
function SearchResults({ query, asyncSearchFn, onItemClick }) {
const { data: results } = useSuspenseQuery({
queryKey: ["search", query],
queryFn: () => asyncSearchFn(query),
});
if (!results || results.length === 0) {
return <span>No results found</span>;
}
return results.map((item, index) => (
<div
key={item.id || index}
className="combobox-option"
onClick={() => onItemClick(item)}
>
{item.name}
</div>
));
}
We can now use this SearchResults
component inside our Combobox
. Then, we can wrap it in a Suspense
component to handle loading states, and additionally an error boundary for error handling:
import { ErrorBoundary } from 'react-error-boundary';
export default function Combobox({
asyncSearchFn,
onSelect,
placeholder = "Search...",
}) {
// ... state variables and handlers
return (
<div>
<input
placeholder={placeholder}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
onFocus={() => setIsOpen(true)}
/>
{isOpen && (
<div className="combobox-container">
<ErrorBoundary fallback={<div>Error loading results</div>}>
<Suspense fallback={<div>Loading results...</div>}>
<SearchResults
query={filterText}
asyncSearchFn={asyncSearchFn}
onItemClick={handleItemClick}
/>
</Suspense>
</ErrorBoundary>
</div>
)}
</div>
);
}
We now have a declarative way to fetch and display search results based on user input! The SearchResults
component will automatically suspend while fetching data, showing a loading state until the results are ready, and it will handle errors gracefully if the fetch fails.
Currently, the SearchResults
component will re-suspend on every input change, which will hide the results until the new data is fetched. This can feel jarring to users, especially if they are typing quickly.
Let's fix this by using useDeferredValue()
to defer the input value updates, and pass this deferred value down to the SearchResults
:
export default function Combobox({
asyncSearchFn,
onSelect,
placeholder = "Search...",
}) {
const [isOpen, setIsOpen] = useState(false);
const [filterText, setFilterText] = useState("");
const deferredFilterText = useDeferredValue(filterText);
const handleItemClick = (item) => {
// ...
};
return (
<div>
<input
placeholder={placeholder}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
onFocus={() => setIsOpen(true)}
/>
{isOpen && (
<div className="combobox-container">
<ErrorBoundary fallback={<div>Error loading results</div>}>
<Suspense fallback={<div>Loading results...</div>}>
<SearchResults
query={deferredFilterText}
asyncSearchFn={asyncSearchFn}
onItemClick={handleItemClick}
/>
</Suspense>
</ErrorBoundary>
</div>
)}
</div>
);
}
Now, as the user types, the SearchResults
component will remain visible with the previous results while the new search is being performed. This creates a smoother user experience, as the results will update once the new data is ready without hiding the previous results.
We will further indicate the stale content by adding an isStale
value:
export default function Combobox({
asyncSearchFn,
onSelect,
placeholder = "Search...",
}) {
const [isOpen, setIsOpen] = useState(false);
const [filterText, setFilterText] = useState("");
const deferredFilterText = useDeferredValue(filterText);
const isStale = filterText !== deferredFilterText;
When the filterText
changes, isStale
is true for as long as useSuspenseQuery()
is fetching new data. We can use this value to add a visual indicator to the stale contents.
<ErrorBoundary fallback={<div>Error loading results</div>}>
<Suspense fallback={<div>Loading results...</div>}>
<div className={isStale ? "animate-pulse" : ""}>
<SearchResults
query={deferredFilterText}
asyncSearchFn={asyncSearchFn}
onItemClick={handleItemClick}
/>
</div>
</Suspense>
</ErrorBoundary>
Perfect!
Keep it mind that useDeferredValue()
itself does not prevent extra network requests from being made. It only defers the rendering of the results. If you type quickly, you will still see multiple requests being sent to the server. However, useSuspenseQuery()
provides built-in caching that automatically deduplicates requests, and shows instant cache hits for repeated queries.
Let's add a min 2 chars requirement for searching:
{deferredFilterText.length < 2 ? (
<div>Type at least 2 characters to search</div>
) : (
<ErrorBoundary fallback={<div>Error loading results</div>}>
<Suspense fallback={<div>Loading results...</div>}>
<div className={isStale ? "animate-pulse" : ""}>
<SearchResults
query={deferredFilterText}
asyncSearchFn={asyncSearchFn}
onItemClick={handleItemClick}
/>
</div>
</Suspense>
</ErrorBoundary>
)}
Here is the complete code for our Combobox
component:
import { useState, useDeferredValue, Suspense } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
export default function Combobox({
asyncSearchFn,
onSelect,
placeholder = "Search...",
}) {
const [isOpen, setIsOpen] = useState(false);
const [filterText, setFilterText] = useState("");
const deferredFilterText = useDeferredValue(filterText);
const isStale = filterText !== deferredFilterText;
const handleItemClick = (item) => {
setFilterText(item.name);
setIsOpen(false);
onSelect?.(item);
};
return (
<div>
<input
placeholder={placeholder}
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
onFocus={() => setIsOpen(true)}
/>
{isOpen && (
<div className="combobox-container">
{deferredFilterText.length < 2 ? (
<div>Type at least 2 characters to search</div>
) : (
<ErrorBoundary fallback={<div>Error loading results</div>}>
<Suspense fallback={<div>Loading results...</div>}>
<div className={isStale ? "animate-pulse" : ""}>
<SearchResults
query={deferredFilterText}
asyncSearchFn={asyncSearchFn}
onItemClick={handleItemClick}
/>
</div>
</Suspense>
</ErrorBoundary>
)}
</div>
)}
</div>
);
}
function SearchResults({ query, asyncSearchFn, onItemClick }) {
const { data: results } = useSuspenseQuery({
queryKey: ["search", query],
queryFn: () => asyncSearchFn(query),
});
if (!results || results.length === 0) {
return <span>No results found</span>;
}
return results.map((item, index) => (
<div
key={item.id || index}
className="combobox-option"
onClick={() => onItemClick(item)}
>
{item.name}
</div>
));
}
This component now provides a smooth and responsive autocomplete experience, leveraging React's concurrent features effectively, while keeping the code declarative and easy to understand.
Check it out in Stackblitz!
In a real app, this component would be extended with more functionality. For example, as noted on X, we should be limiting the number of visible results for a search to avoid lag when React commits to the DOM. And we could also add a debounce to prevent excessive calls to the search function while the user is typing - this version can be found in the Stackblitz example.
The patterns demonstrated are applicable to a lot more cases than just a combobox component! However, for this blog post, a simple version is enough to grasp the main concepts.
useSuspenseQuery()
keeps concerns isolateduseDeferredValue()
isn't just for rendering optimization—it creates smooth stale-while-revalidate UX when combined with Suspense-enabled data sourcesIn this post, we explored how to build responsive search interfaces using React's concurrent features. We looked at how useDeferredValue()
and useSuspenseQuery()
work together to create smooth user experiences, from basic component structure to advanced stale-while-revalidate patterns. By following the essential pattern of separating immediate user interactions from deferred data fetching, we can create components that are performant, declarative, and easy to maintain.
For a comprehensive overview of all React concurrent features, check out React Concurrent Features: An Overview.
Sources:
Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.
Building an Async Combobox with useSuspenseQuery() and useDeferredValue()
Learn how to build a responsive async combobox component using React's useDeferredValue() and useSuspenseQuery(). Discover how these concurrent features work together to create smooth user experiences with declarative loading states, optimistic updates, and automatic caching for search interfaces.
Aurora Scharff
Oct 14, 2025
Subject, BehaviorSubject, and ReplaySubject
Angular’s reactive world revolves around Subjects, but the star is often the BehaviorSubject — why? Because it always starts with a value, and when you subscribe, you immediately get the latest emission (like receiving the current issue of a magazine). Meanwhile, ReplaySubject lets you go further by replaying multiple past values (no default), and a plain Subject is just “live only” — no past, no future. Let’s dig into when and why to use each.
Alain Chautard
Oct 8, 2025
Unravel the mystery of JavaScript's event bubbling and take control of your event handling
Ever clicked a button and had unexpected things happen to its parent elements? You might have just witnessed the magic (or mischief!) of JavaScript's event bubbling. It's a core concept that, once understood, will save you headaches and help you debug event-related issues.
Martin Ferret
Oct 8, 2025
We can help you recruit Certified Developers for your organization or project. The team has helped many customers employ suitable resources from a pool of 100s of qualified Developers.
Let us help you get the resources you need.