
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:
lazyuseWhen 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.

React Free Weekend: 48 hours of Open Access to Premium React Certification Training
Every year, we at Certificates.dev look for ways to make our certifications more accessible - not just for teams inside big tech companies, but for every developer who wants to validate their skills through real-world, hands-on learning. That’s why we are excited to announce our first-ever React Free Weekend, taking place on November 15–16, 2025.
Aurora Scharff
Nov 11, 2025

Error Handling in React with react-error-boundary
Learn how to handle errors in React applications with react-error-boundary. Explore fallback UIs, async error handling with useErrorBoundary, and React 19's automatic error boundary integration with form actions and useTransition.
Aurora Scharff
Nov 10, 2025

Document Fragments: The Secret to Fast, Clean DOM Manipulation in JavaScript
Discover how DocumentFragment can dramatically speed up DOM manipulation in JavaScript. Learn how to batch element creation in memory, avoid reflows, and build smoother, faster interfaces with clean, efficient code.
Martin Ferret
Nov 6, 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.
