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

npmixnpmix
232
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
// 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:

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

tsx
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

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

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

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

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

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


Similar articles

Never miss an update

Subscribe to receive news and special offers.

By subscribing you agree to our Privacy Policy.