React useSearch Hook – Build a Smarter Search System in Your App

React useSearch Hook – Build a Smarter Search System in Your App
The Problem: Why Basic Search Features Fall Short
Have you ever used a search feature that only works if you type the exact word? Or one that shows too many results with no way to narrow them down? These are common problems with basic search implementations.
What most developers do:
// Basic search that most developers implement
const filteredItems = items.filter(item =>
item.name.toLowerCase().includes(searchQuery.toLowerCase())
);
This simple approach has several limitations:
- ❌ Only searches in one field (like name or title)
- ❌ No way to handle typos or similar words
- ❌ Shows all results at once (no pagination)
- ❌ Can't filter by categories or other properties
Introducing the useSearch Hook: A Complete Solution
The useSearch
hook we'll build solves all these problems in one reusable package. Think of it as a Swiss Army knife for search functionality in your React apps.
What You'll Get:
- 🔍 Multi-field search - Search across titles, descriptions, tags, and more
- 📋 Smart filtering - Filter by any property (category, status, date, etc.)
- 📄 Built-in pagination - Show results in manageable chunks
- 🔤 Fuzzy matching - Find "programming" even when users type "programing"
- ⚡ Performance optimized - Only recalculates when necessary
How It Works: A Simple Example
Here's how easy it is to use our hook:
// Import the hook
import { useSearch } from './hooks/useSearch';
function ProductList() {
// Your data and search state
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState(null);
// Use the hook with your data
const {
results, // Current page of filtered items
total, // Total number of matches
currentPage, // Current page number
totalPages, // Total number of pages
setCurrentPage // Function to change page
} = useSearch({
data: products, // Your data array
query: searchQuery, // Search text
filters: { category: selectedCategory }, // Any filters you want
keys: ['name', 'description', 'tags'], // Fields to search in
itemsPerPage: 10 // Items per page
});
return (
<div>
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search products..."
/>
{/* Show results count */}
<p>Found {total} products</p>
{/* Display results */}
{results.map(product => (
<ProductCard key={product.id} product={product} />
))}
{/* Pagination controls */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setCurrentPage}
/>
</div>
);
}
Building the Hook Step by Step
Let's break down how to build this powerful hook:
Step 1: Set Up the Basic Structure
import { useMemo, useState } from 'react';
// Define the hook's parameters
type UseSearchProps<T> = {
data: T[]; // Your data array
query: string; // Search text
filters?: Record<string, any>; // Optional filters
keys?: (keyof T)[]; // Fields to search in
itemsPerPage?: number; // Items per page
};
export function useSearch<T>({
data,
query,
filters = {},
keys = [],
itemsPerPage = 10
}: UseSearchProps<T>) {
// Hook implementation will go here
}
Step 2: Implement Filtering Logic
// Inside the useSearch hook:
// Store current page
const [currentPage, setCurrentPage] = useState(1);
// Memoize filtered results to avoid recalculating on every render
const filtered = useMemo(() => {
// Start with all data
let results = [...data];
// Apply filters first (this is faster than text search)
if (filters && Object.keys(filters).length > 0) {
Object.entries(filters).forEach(([key, value]) => {
// Only apply filter if value isn't null/undefined
if (value !== null && value !== undefined) {
results = results.filter(item => {
// Handle array values (like tags)
if (Array.isArray(item[key as keyof T])) {
return (item[key as keyof T] as any[]).includes(value);
}
// Handle regular values
return item[key as keyof T] === value;
});
}
});
}
// Then apply text search if query exists
if (query.trim()) {
const normalizedQuery = query.toLowerCase().trim();
results = results.filter(item => {
// Search in all specified keys
return keys.some(key => {
const value = item[key];
// Skip if value doesn't exist
if (value === null || value === undefined) return false;
// Handle arrays (like tags)
if (Array.isArray(value)) {
return value.some(v =>
String(v).toLowerCase().includes(normalizedQuery)
);
}
// Handle regular values
return String(value).toLowerCase().includes(normalizedQuery);
});
});
}
return results;
}, [data, query, filters, keys]); // Only recalculate when these values change
// Calculate pagination values
const totalItems = filtered.length;
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
// Ensure current page is valid
useEffect(() => {
if (currentPage > totalPages) {
setCurrentPage(1);
}
}, [totalPages, currentPage]);
// Get current page of results
const paginatedResults = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return filtered.slice(startIndex, startIndex + itemsPerPage);
}, [filtered, currentPage, itemsPerPage]);
// Return everything the component needs
return {
results: paginatedResults,
total: totalItems,
currentPage,
totalPages,
setCurrentPage,
};
Adding Fuzzy Search for Better Results
Regular search breaks when users make typos. Let's add fuzzy search to fix that:
// A simple function to calculate how similar two strings are
function calculateSimilarity(str1: string, str2: string): number {
// Convert strings to sets of 3-character sequences
const createTrigrams = (text: string) => {
const trigrams = new Set<string>();
for (let i = 0; i < text.length - 2; i++) {
trigrams.add(text.substring(i, i + 3));
}
return trigrams;
};
// Get trigrams for both strings
const set1 = createTrigrams(str1);
const set2 = createTrigrams(str2);
// Calculate intersection
let matchCount = 0;
set1.forEach(trigram => {
if (set2.has(trigram)) matchCount++;
});
// Calculate similarity score (0 to 1)
return matchCount / Math.max(set1.size, set2.size, 1);
}
// Then in the search logic:
return keys.some(key => {
const value = String(item[key] || '').toLowerCase();
// Use exact match first
if (value.includes(normalizedQuery)) return true;
// Fall back to fuzzy match if no exact match
return calculateSimilarity(value, normalizedQuery) > 0.3; // Adjust threshold as needed
});
Real-World Use Cases
Our useSearch
hook is perfect for:
-
E-commerce product catalogs: Let users search and filter products by category, price range, ratings, etc.
-
Content management: Search articles by title, content, author, or tags with pagination for large collections.
-
User directories: Find users by name, role, or department with smart filtering.
-
Documentation sites: Search through docs with fuzzy matching to handle typos.
Taking It Further
Here are some ways to enhance the hook even more:
1. Add Sorting Options
// Add to props
type SortDirection = 'asc' | 'desc';
sortBy?: keyof T;
sortDirection?: SortDirection;
// Add to filtering logic
if (sortBy) {
filtered.sort((a, b) => {
const valueA = a[sortBy];
const valueB = b[sortBy];
// Handle string comparison
if (typeof valueA === 'string' && typeof valueB === 'string') {
return sortDirection === 'desc'
? valueB.localeCompare(valueA)
: valueA.localeCompare(valueB);
}
// Handle number comparison
return sortDirection === 'desc'
? Number(valueB) - Number(valueA)
: Number(valueA) - Number(valueB);
});
}
2. Sync with URL Parameters
// Use the URL to store search state
import { useRouter } from 'next/router';
// Inside your component:
const router = useRouter();
const { q, category, page } = router.query;
// Initialize state from URL
const [searchQuery, setSearchQuery] = useState(q || '');
const [selectedCategory, setSelectedCategory] = useState(category || null);
// Update URL when search changes
useEffect(() => {
router.push({
pathname: router.pathname,
query: {
q: searchQuery || undefined,
category: selectedCategory || undefined,
page: currentPage > 1 ? currentPage : undefined
}
}, undefined, { shallow: true });
}, [searchQuery, selectedCategory, currentPage]);
3. Support Async Data Sources
// Create an async version for API data
function useAsyncSearch<T>({
fetchFn,
query,
filters,
page = 1,
itemsPerPage = 10
}) {
const [loading, setLoading] = useState(false);
const [results, setResults] = useState<T[]>([]);
const [total, setTotal] = useState(0);
useEffect(() => {
let isMounted = true;
setLoading(true);
fetchFn({ query, filters, page, itemsPerPage })
.then(response => {
if (isMounted) {
setResults(response.data);
setTotal(response.total);
setLoading(false);
}
})
.catch(error => {
console.error('Search error:', error);
if (isMounted) setLoading(false);
});
return () => { isMounted = false; };
}, [query, filters, page, itemsPerPage]);
return {
results,
total,
loading,
currentPage: page,
totalPages: Math.ceil(total / itemsPerPage)
};
}
Conclusion: Build Once, Use Everywhere
The useSearch
hook transforms complex search functionality into a simple, reusable package. By building it once and using it across your projects, you'll:
- ⏱️ Save development time on every project
- 🧠 Reduce cognitive load by abstracting search complexity
- 🚀 Improve user experience with smart, responsive search
Try implementing this hook in your next project, and you'll wonder how you ever built search features without it!