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:
1// Basic search that most developers implement
2const filteredItems = items.filter(item =>
3 item.name.toLowerCase().includes(searchQuery.toLowerCase())
4);
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:
1// Import the hook
2import { useSearch } from './hooks/useSearch';
3
4function ProductList() {
5 // Your data and search state
6 const [searchQuery, setSearchQuery] = useState('');
7 const [selectedCategory, setSelectedCategory] = useState(null);
8
9 // Use the hook with your data
10 const {
11 results, // Current page of filtered items
12 total, // Total number of matches
13 currentPage, // Current page number
14 totalPages, // Total number of pages
15 setCurrentPage // Function to change page
16 } = useSearch({
17 data: products, // Your data array
18 query: searchQuery, // Search text
19 filters: { category: selectedCategory }, // Any filters you want
20 keys: ['name', 'description', 'tags'], // Fields to search in
21 itemsPerPage: 10 // Items per page
22 });
23
24 return (
25 <div>
26 <input
27 value={searchQuery}
28 onChange={(e) => setSearchQuery(e.target.value)}
29 placeholder="Search products..."
30 />
31
32 {/* Show results count */}
33 <p>Found {total} products</p>
34
35 {/* Display results */}
36 {results.map(product => (
37 <ProductCard key={product.id} product={product} />
38 ))}
39
40 {/* Pagination controls */}
41 <Pagination
42 currentPage={currentPage}
43 totalPages={totalPages}
44 onPageChange={setCurrentPage}
45 />
46 </div>
47 );
48}
Building the Hook Step by Step
Let's break down how to build this powerful hook:
Step 1: Set Up the Basic Structure
1import { useMemo, useState } from 'react';
2
3// Define the hook's parameters
4type UseSearchProps<T> = {
5 data: T[]; // Your data array
6 query: string; // Search text
7 filters?: Record<string, any>; // Optional filters
8 keys?: (keyof T)[]; // Fields to search in
9 itemsPerPage?: number; // Items per page
10};
11
12export function useSearch<T>({
13 data,
14 query,
15 filters = {},
16 keys = [],
17 itemsPerPage = 10
18}: UseSearchProps<T>) {
19 // Hook implementation will go here
20}
Step 2: Implement Filtering Logic
1// Inside the useSearch hook:
2
3// Store current page
4const [currentPage, setCurrentPage] = useState(1);
5
6// Memoize filtered results to avoid recalculating on every render
7const filtered = useMemo(() => {
8 // Start with all data
9 let results = [...data];
10
11 // Apply filters first (this is faster than text search)
12 if (filters && Object.keys(filters).length > 0) {
13 Object.entries(filters).forEach(([key, value]) => {
14 // Only apply filter if value isn't null/undefined
15 if (value !== null && value !== undefined) {
16 results = results.filter(item => {
17 // Handle array values (like tags)
18 if (Array.isArray(item[key as keyof T])) {
19 return (item[key as keyof T] as any[]).includes(value);
20 }
21 // Handle regular values
22 return item[key as keyof T] === value;
23 });
24 }
25 });
26 }
27
28 // Then apply text search if query exists
29 if (query.trim()) {
30 const normalizedQuery = query.toLowerCase().trim();
31
32 results = results.filter(item => {
33 // Search in all specified keys
34 return keys.some(key => {
35 const value = item[key];
36
37 // Skip if value doesn't exist
38 if (value === null || value === undefined) return false;
39
40 // Handle arrays (like tags)
41 if (Array.isArray(value)) {
42 return value.some(v =>
43 String(v).toLowerCase().includes(normalizedQuery)
44 );
45 }
46
47 // Handle regular values
48 return String(value).toLowerCase().includes(normalizedQuery);
49 });
50 });
51 }
52
53 return results;
54}, [data, query, filters, keys]); // Only recalculate when these values change
55
56// Calculate pagination values
57const totalItems = filtered.length;
58const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
59
60// Ensure current page is valid
61useEffect(() => {
62 if (currentPage > totalPages) {
63 setCurrentPage(1);
64 }
65}, [totalPages, currentPage]);
66
67// Get current page of results
68const paginatedResults = useMemo(() => {
69 const startIndex = (currentPage - 1) * itemsPerPage;
70 return filtered.slice(startIndex, startIndex + itemsPerPage);
71}, [filtered, currentPage, itemsPerPage]);
72
73// Return everything the component needs
74return {
75 results: paginatedResults,
76 total: totalItems,
77 currentPage,
78 totalPages,
79 setCurrentPage,
80};
Adding Fuzzy Search for Better Results
Regular search breaks when users make typos. Let's add fuzzy search to fix that:
1// A simple function to calculate how similar two strings are
2function calculateSimilarity(str1: string, str2: string): number {
3 // Convert strings to sets of 3-character sequences
4 const createTrigrams = (text: string) => {
5 const trigrams = new Set<string>();
6 for (let i = 0; i < text.length - 2; i++) {
7 trigrams.add(text.substring(i, i + 3));
8 }
9 return trigrams;
10 };
11
12 // Get trigrams for both strings
13 const set1 = createTrigrams(str1);
14 const set2 = createTrigrams(str2);
15
16 // Calculate intersection
17 let matchCount = 0;
18 set1.forEach(trigram => {
19 if (set2.has(trigram)) matchCount++;
20 });
21
22 // Calculate similarity score (0 to 1)
23 return matchCount / Math.max(set1.size, set2.size, 1);
24}
25
26// Then in the search logic:
27return keys.some(key => {
28 const value = String(item[key] || '').toLowerCase();
29
30 // Use exact match first
31 if (value.includes(normalizedQuery)) return true;
32
33 // Fall back to fuzzy match if no exact match
34 return calculateSimilarity(value, normalizedQuery) > 0.3; // Adjust threshold as needed
35});
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
1// Add to props
2type SortDirection = 'asc' | 'desc';
3sortBy?: keyof T;
4sortDirection?: SortDirection;
5
6// Add to filtering logic
7if (sortBy) {
8 filtered.sort((a, b) => {
9 const valueA = a[sortBy];
10 const valueB = b[sortBy];
11
12 // Handle string comparison
13 if (typeof valueA === 'string' && typeof valueB === 'string') {
14 return sortDirection === 'desc'
15 ? valueB.localeCompare(valueA)
16 : valueA.localeCompare(valueB);
17 }
18
19 // Handle number comparison
20 return sortDirection === 'desc'
21 ? Number(valueB) - Number(valueA)
22 : Number(valueA) - Number(valueB);
23 });
24}
2. Sync with URL Parameters
1// Use the URL to store search state
2import { useRouter } from 'next/router';
3
4// Inside your component:
5const router = useRouter();
6const { q, category, page } = router.query;
7
8// Initialize state from URL
9const [searchQuery, setSearchQuery] = useState(q || '');
10const [selectedCategory, setSelectedCategory] = useState(category || null);
11
12// Update URL when search changes
13useEffect(() => {
14 router.push({
15 pathname: router.pathname,
16 query: {
17 q: searchQuery || undefined,
18 category: selectedCategory || undefined,
19 page: currentPage > 1 ? currentPage : undefined
20 }
21 }, undefined, { shallow: true });
22}, [searchQuery, selectedCategory, currentPage]);
3. Support Async Data Sources
1// Create an async version for API data
2function useAsyncSearch<T>({
3 fetchFn,
4 query,
5 filters,
6 page = 1,
7 itemsPerPage = 10
8}) {
9 const [loading, setLoading] = useState(false);
10 const [results, setResults] = useState<T[]>([]);
11 const [total, setTotal] = useState(0);
12
13 useEffect(() => {
14 let isMounted = true;
15 setLoading(true);
16
17 fetchFn({ query, filters, page, itemsPerPage })
18 .then(response => {
19 if (isMounted) {
20 setResults(response.data);
21 setTotal(response.total);
22 setLoading(false);
23 }
24 })
25 .catch(error => {
26 console.error('Search error:', error);
27 if (isMounted) setLoading(false);
28 });
29
30 return () => { isMounted = false; };
31 }, [query, filters, page, itemsPerPage]);
32
33 return {
34 results,
35 total,
36 loading,
37 currentPage: page,
38 totalPages: Math.ceil(total / itemsPerPage)
39 };
40}
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!