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

Andry Dina
Custom useSearch Hook in React

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:

tsx
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:

tsx
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

tsx
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

tsx
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:

tsx
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

tsx
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

tsx
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

tsx
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!


Join our newsletter for the
latest update

By subscribing you agree to receive the Paddle newsletter. Unsubscribe at any time.