Skip to main content

Build Filterable Search with React Transitions

A filterable search that stays responsive even with thousands of items is a real-world test of concurrent rendering. In this article, you'll build a production-grade search component that handles 100,000+ items without freezing the UI, using useTransition to deprioritize the expensive filter and keep the input field instantly responsive. This is the kind of pattern you'll use in dashboards, product catalogs, and admin panels.

The key is separating concerns: the input update (high priority) from the filter computation (low priority). React's concurrent rendering makes this effortless if you use useTransition correctly.

Full Filterable Search Component

Here's a complete, copy-paste-ready component that filters a large dataset:

import { useState, useTransition, useMemo } from 'react';

export function FilterableProductSearch() {
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all');
const [isPending, startTransition] = useTransition();

// Memoize the dataset so it doesn't regenerate on every render
const allProducts = useMemo(() => generateProducts(100000), []);

// Filter function (this is what gets deferred)
const filteredProducts = useMemo(() => {
if (!searchQuery && selectedCategory === 'all') {
return allProducts;
}

return allProducts.filter(product => {
const matchesQuery = searchQuery === '' ||
product.name.toLowerCase().includes(searchQuery.toLowerCase());

const matchesCategory = selectedCategory === 'all' ||
product.category === selectedCategory;

return matchesQuery && matchesCategory;
});
}, [allProducts, searchQuery, selectedCategory]);

// Handle search input changes
const handleSearchChange = (e) => {
const newQuery = e.target.value;

// Update input immediately
setSearchQuery(newQuery);

// Defer the filter computation
startTransition(() => {
// The filter logic is inside useMemo, so it re-runs when
// searchQuery changes (inside the transition)
});
};

// Handle category filter changes
const handleCategoryChange = (e) => {
const newCategory = e.target.value;

setSelectedCategory(newCategory);

startTransition(() => {
// Again, the filter re-computes due to selectedCategory change
});
};

return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Product Search</h1>

{/* Search Input */}
<input
type="text"
value={searchQuery}
onChange={handleSearchChange}
placeholder="Search 100k products by name..."
style={{
padding: '10px',
fontSize: '16px',
width: '100%',
marginBottom: '10px',
border: '1px solid #ccc',
borderRadius: '4px',
}}
/>

{/* Category Filter */}
<select
value={selectedCategory}
onChange={handleCategoryChange}
style={{
padding: '10px',
fontSize: '14px',
marginBottom: '10px',
border: '1px solid #ccc',
borderRadius: '4px',
}}
>
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
<option value="books">Books</option>
</select>

{/* Pending Indicator */}
{isPending && (
<div style={{
padding: '10px',
backgroundColor: '#f0f0f0',
borderRadius: '4px',
marginBottom: '10px',
color: '#666',
}}>
Filtering {filteredProducts.length} products...
</div>
)}

{/* Results Count */}
<p style={{ color: '#666', marginBottom: '10px' }}>
Found {filteredProducts.length} result{filteredProducts.length !== 1 ? 's' : ''}
</p>

{/* Product List */}
{filteredProducts.length === 0 ? (
<p>No products found. Try a different search or category.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{filteredProducts.slice(0, 50).map(product => (
<li
key={product.id}
style={{
padding: '12px',
borderBottom: '1px solid #eee',
}}
>
<strong>{product.name}</strong>
<br />
<span style={{ fontSize: '12px', color: '#999' }}>
{product.category} - ${product.price.toFixed(2)}
</span>
</li>
))}
</ul>
)}

{filteredProducts.length > 50 && (
<p style={{ color: '#999', fontSize: '12px' }}>
Showing 50 of {filteredProducts.length} results
</p>
)}
</div>
);
}

// Helper to generate a large dataset
function generateProducts(count) {
const categories = ['electronics', 'clothing', 'books', 'toys'];

return Array.from({ length: count }, (_, i) => ({
id: i,
name: `Product ${i}`,
category: categories[i % categories.length],
price: Math.random() * 500,
}));
}

Walk through this component:

  1. Input updates immediately: setSearchQuery(newQuery) happens outside startTransition, so the input field responds instantly.
  2. Filter is deferred: The useMemo with dependencies [allProducts, searchQuery, selectedCategory] re-computes whenever these change. But because the dependencies change inside startTransition, React deprioritizes the filter.
  3. isPending shows feedback: While the filter is running, isPending is true, so the user sees a "Filtering..." message.
  4. Results update when done: Once the filter completes, the filteredProducts list updates and renders on screen.

Optimizing with Virtual Scrolling

For truly massive lists (100k+ items), render only the visible items using a library like react-window or react-virtualized:

import { useState, useTransition, useMemo } from 'react';
import { FixedSizeList as List } from 'react-window';

export function VirtualFilterableSearch() {
const [searchQuery, setSearchQuery] = useState('');
const [isPending, startTransition] = useTransition();

const allProducts = useMemo(() => generateProducts(100000), []);

const filteredProducts = useMemo(() => {
return allProducts.filter(p =>
p.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [allProducts, searchQuery]);

const handleSearch = (e) => {
setSearchQuery(e.target.value);
startTransition(() => {});
};

// Virtual list row renderer
const Row = ({ index, style }) => {
const product = filteredProducts[index];
return (
<div style={{ ...style, padding: '8px', borderBottom: '1px solid #eee' }}>
<strong>{product.name}</strong>
<br />
<span style={{ fontSize: '12px', color: '#999' }}>
${product.price.toFixed(2)}
</span>
</div>
);
};

return (
<div style={{ padding: '20px' }}>
<h1>Virtual Filterable Search</h1>

<input
type="text"
value={searchQuery}
onChange={handleSearch}
placeholder="Search..."
style={{ width: '100%', padding: '10px', marginBottom: '10px' }}
/>

{isPending && <p>Filtering...</p>}

{filteredProducts.length > 0 ? (
<List
height={500}
itemCount={filteredProducts.length}
itemSize={50}
width="100%"
>
{Row}
</List>
) : (
<p>No results found.</p>
)}
</div>
);
}

function generateProducts(count) {
return Array.from({ length: count }, (_, i) => ({
id: i,
name: `Product ${i}`,
price: Math.random() * 500,
}));
}

Virtual scrolling renders only ~10–15 rows at a time, keeping the DOM small and scrolling smooth.

Combining useTransition with useDeferredValue

You can use both together for maximum control—useTransition for manual feedback, useDeferredValue for automatic debouncing:

import { useState, useTransition, useDeferredValue } from 'react';

export function HybridSearch() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const [isPending, startTransition] = useTransition();

const results = useMemo(
() => expensiveFilter(deferredQuery),
[deferredQuery]
);

return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>

{query !== deferredQuery && <p>Searching...</p>}

<ul>
{results.map(r => <li key={r.id}>{r.title}</li>)}
</ul>
</div>
);
}

Key Takeaways

  • Separate input updates (high priority) from filter computation (low priority) using useTransition.
  • Use useMemo to memoize both the dataset and the filtered results to avoid unnecessary recalculations.
  • isPending provides visual feedback while the filter runs.
  • For very large lists (>10k items), combine with virtual scrolling to keep the DOM lean.
  • Test your component with realistic data sizes and browser DevTools profiling.

Frequently Asked Questions

Why doesn't the filter work if I don't wrap it in startTransition?

It does work, but without startTransition, the filter blocks the main thread and the input feels frozen. startTransition tells React to defer the filter, keeping the input responsive.

Should I always use useMemo for the filter?

Yes, for expensive filters. useMemo prevents the filter from re-running unnecessarily. Without it, the filter runs on every render, which is wasteful.

Can I combine this with a server-side API?

Yes. Fetch results from the server inside startTransition, then update state with the results. The input will stay responsive while the fetch is pending.

How do I test that the filter is actually deferred?

Use React DevTools Profiler to record the component's renders. A deferred render will show as two separate renders: one for the input (fast) and one for the filter (slow).

Further Reading