AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

Search Filter Hook: Debounce & Multi-Field (2026)

Last updated:
Search Filter Hook: Debounce & Multi-Field (2026)

Build production-ready search filtering with useSearch hook. Master debouncing, multi-field filtering, fuzzy matching, and performance optimization with complete TypeScript examples.

# Search Filter Hook: Debounce & Multi-Field (2026)

You've added a search box to your table. Type "John" and the app freezes for 200ms while filtering 10,000 rows. Type quickly and it filters on every keystroke—"J", "Jo", "Joh", "John"—four expensive operations when you only needed the last one. Sound familiar?

This guide shows you how to build a production-ready useSearch hook that handles debouncing, multi-field filtering, fuzzy matching, and performance optimization. You'll learn when to filter, how to filter efficiently, and how to make filtering feel instant even with large datasets.

# Table of Contents

  1. Why Build a Search Filter Hook?
  2. Basic Implementation: Simple Filter
  3. Performance Optimization with useMemo
  4. Debouncing User Input
  5. Multi-Field Searching
  6. Advanced Filtering Options
  7. Fuzzy Matching Implementation
  8. Highlight Search Terms
  9. Server-Side Search Integration
  10. Complete Hook: useSearch
  11. Practical Example: GitHub Users Search
  12. FAQ

# Why Build a Search Filter Hook?

Consider implementing search across multiple components:

typescript
// Component 1: User search
function UserList() {
  const [search, setSearch] = useState('');
  const filtered = users.filter(u => 
    u.name.toLowerCase().includes(search.toLowerCase())
  );
  // No debounce, filters on every keystroke
}

// Component 2: Product search  
function ProductCatalog() {
  const [search, setSearch] = useState('');
  const filtered = products.filter(p => 
    p.name.toLowerCase().includes(search.toLowerCase())
  );
  // Same logic duplicated
}

Problems with duplication:

  • Inconsistent filter logic
  • No performance optimization
  • Every component implements own debouncing
  • Different search behaviors across app
  • Hard to add features like fuzzy matching

A custom hook solves all of this.

# Basic Implementation: Simple Filter

Start with the simplest version—filter an array by search term:

# TypeScript Version

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

interface UseSearchProps<T> {
  items: T[];
  searchFields: (keyof T)[];
}

interface UseSearchReturn<T> {
  searchTerm: string;
  setSearchTerm: (term: string) => void;
  filteredItems: T[];
}

function useSearch<T>({
  items,
  searchFields,
}: UseSearchProps<T>): UseSearchReturn<T> {
  const [searchTerm, setSearchTerm] = useState('');

  const filteredItems = useMemo(() => {
    if (!searchTerm) return items;

    const lowerSearch = searchTerm.toLowerCase();

    return items.filter((item) => {
      return searchFields.some((field) => {
        const value = item[field];
        if (typeof value === 'string') {
          return value.toLowerCase().includes(lowerSearch);
        }
        if (typeof value === 'number') {
          return value.toString().includes(lowerSearch);
        }
        return false;
      });
    });
  }, [items, searchTerm, searchFields]);

  return {
    searchTerm,
    setSearchTerm,
    filteredItems,
  };
}

// Usage
interface User {
  id: number;
  name: string;
  email: string;
}

function UserList({ users }: { users: User[] }) {
  const { searchTerm, setSearchTerm, filteredItems } = useSearch({
    items: users,
    searchFields: ['name', 'email'],
  });

  return (
    <div>
      <input
        type="search"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search users..."
      />

      <ul>
        {filteredItems.map((user) => (
          <li key={user.id}>
            {user.name} ({user.email})
          </li>
        ))}
      </ul>
    </div>
  );
}

# JavaScript Version

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

function useSearch({ items, searchFields }) {
  const [searchTerm, setSearchTerm] = useState('');

  const filteredItems = useMemo(() => {
    if (!searchTerm) return items;

    const lowerSearch = searchTerm.toLowerCase();

    return items.filter((item) => {
      return searchFields.some((field) => {
        const value = item[field];
        if (typeof value === 'string') {
          return value.toLowerCase().includes(lowerSearch);
        }
        if (typeof value === 'number') {
          return value.toString().includes(lowerSearch);
        }
        return false;
      });
    });
  }, [items, searchTerm, searchFields]);

  return {
    searchTerm,
    setSearchTerm,
    filteredItems,
  };
}

Key features:

  • Case-insensitive search
  • Multiple field support
  • Type checking for string/number fields
  • Returns empty query → all items

# Performance Optimization with useMemo

Without useMemo, filtering recalculates on every render—even when unrelated state changes:

# TypeScript Version

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

// ❌ BAD: Re-filters on every render
function useSearchBad<T>({ items, searchFields }: UseSearchProps<T>) {
  const [searchTerm, setSearchTerm] = useState('');
  const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');

  // This runs EVERY render, even when only sortOrder changes!
  const filteredItems = items.filter((item) => {
    // expensive filtering logic...
  });

  return { filteredItems, searchTerm, setSearchTerm };
}

// ✅ GOOD: Only re-filters when dependencies change
function useSearchGood<T>({ items, searchFields }: UseSearchProps<T>) {
  const [searchTerm, setSearchTerm] = useState('');
  const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');

  // Only runs when items or searchTerm changes
  const filteredItems = useMemo(() => {
    if (!searchTerm) return items;

    const lowerSearch = searchTerm.toLowerCase();
    return items.filter((item) => {
      return searchFields.some((field) => {
        const value = item[field];
        return typeof value === 'string' 
          ? value.toLowerCase().includes(lowerSearch)
          : false;
      });
    });
  }, [items, searchTerm, searchFields]);

  // Sorting happens separately, doesn't trigger filter recalc
  const sortedAndFiltered = useMemo(() => {
    return [...filteredItems].sort((a, b) => {
      // sorting logic based on sortOrder
      return 0;
    });
  }, [filteredItems, sortOrder]);

  return { 
    filteredItems: sortedAndFiltered, 
    searchTerm, 
    setSearchTerm 
  };
}

Performance impact:

  • 100 items: ~1ms saved per render
  • 1,000 items: ~10ms saved per render
  • 10,000 items: ~100ms saved per render

When filtering is expensive:

  • Large datasets (1000+ items)
  • Complex filter logic (regex, multiple conditions)
  • Frequent re-renders (animations, timers)

# Debouncing User Input

Filtering on every keystroke wastes CPU. Debounce delays filtering until user stops typing:

# TypeScript Version

typescript
import { useState, useMemo, useEffect } from 'react';

interface UseSearchWithDebounceProps<T> {
  items: T[];
  searchFields: (keyof T)[];
  debounceMs?: number;
}

function useSearchWithDebounce<T>({
  items,
  searchFields,
  debounceMs = 300,
}: UseSearchWithDebounceProps<T>) {
  const [searchTerm, setSearchTerm] = useState('');
  const [debouncedTerm, setDebouncedTerm] = useState('');

  // Debounce the search term
  useEffect(() => {
    const timerId = setTimeout(() => {
      setDebouncedTerm(searchTerm);
    }, debounceMs);

    return () => clearTimeout(timerId);
  }, [searchTerm, debounceMs]);

  // Filter using debounced term
  const filteredItems = useMemo(() => {
    if (!debouncedTerm) return items;

    const lowerSearch = debouncedTerm.toLowerCase();

    return items.filter((item) => {
      return searchFields.some((field) => {
        const value = item[field];
        if (typeof value === 'string') {
          return value.toLowerCase().includes(lowerSearch);
        }
        if (typeof value === 'number') {
          return value.toString().includes(lowerSearch);
        }
        return false;
      });
    });
  }, [items, debouncedTerm, searchFields]);

  // Show loading state when typing but not yet filtered
  const isSearching = searchTerm !== debouncedTerm;

  return {
    searchTerm,
    setSearchTerm,
    filteredItems,
    isSearching,
  };
}

// Usage with loading indicator
function UserSearch({ users }: { users: User[] }) {
  const { 
    searchTerm, 
    setSearchTerm, 
    filteredItems, 
    isSearching 
  } = useSearchWithDebounce({
    items: users,
    searchFields: ['name', 'email'],
    debounceMs: 300,
  });

  return (
    <div>
      <div>
        <input
          type="search"
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          placeholder="Search users..."
        />
        {isSearching && <span>Searching...</span>}
      </div>

      <p>Showing {filteredItems.length} results</p>

      <ul>
        {filteredItems.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

# JavaScript Version

javascript
import { useState, useMemo, useEffect } from 'react';

function useSearchWithDebounce({
  items,
  searchFields,
  debounceMs = 300,
}) {
  const [searchTerm, setSearchTerm] = useState('');
  const [debouncedTerm, setDebouncedTerm] = useState('');

  useEffect(() => {
    const timerId = setTimeout(() => {
      setDebouncedTerm(searchTerm);
    }, debounceMs);

    return () => clearTimeout(timerId);
  }, [searchTerm, debounceMs]);

  const filteredItems = useMemo(() => {
    if (!debouncedTerm) return items;

    const lowerSearch = debouncedTerm.toLowerCase();

    return items.filter((item) => {
      return searchFields.some((field) => {
        const value = item[field];
        if (typeof value === 'string') {
          return value.toLowerCase().includes(lowerSearch);
        }
        if (typeof value === 'number') {
          return value.toString().includes(lowerSearch);
        }
        return false;
      });
    });
  }, [items, debouncedTerm, searchFields]);

  const isSearching = searchTerm !== debouncedTerm;

  return {
    searchTerm,
    setSearchTerm,
    filteredItems,
    isSearching,
  };
}

How debouncing works:

  1. User types "John"
  2. Timer starts for "J" (300ms)
  3. User types "o" before timer expires
  4. Timer clears and restarts for "Jo"
  5. Repeats for "h" and "n"
  6. Timer expires 300ms after "n"
  7. Search executes once for "John"

Optimal debounce delays:

  • Fast typing: 150-200ms
  • Normal typing: 300ms (recommended)
  • Slow typing: 500ms
  • Server search: 500-800ms

# Multi-Field Searching

Real apps need to search across multiple fields with different strategies:

# TypeScript Version

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

type SearchStrategy = 'includes' | 'startsWith' | 'exact';

interface SearchFieldConfig<T> {
  field: keyof T;
  strategy?: SearchStrategy;
  weight?: number; // For ranking results
}

interface UseAdvancedSearchProps<T> {
  items: T[];
  searchFields: SearchFieldConfig<T>[];
  caseSensitive?: boolean;
}

function useAdvancedSearch<T>({
  items,
  searchFields,
  caseSensitive = false,
}: UseAdvancedSearchProps<T>) {
  const [searchTerm, setSearchTerm] = useState('');

  const filteredItems = useMemo(() => {
    if (!searchTerm) return items;

    const search = caseSensitive 
      ? searchTerm 
      : searchTerm.toLowerCase();

    return items
      .map((item) => {
        let score = 0;

        for (const config of searchFields) {
          const value = item[config.field];
          if (typeof value !== 'string' && typeof value !== 'number') {
            continue;
          }

          const fieldValue = caseSensitive
            ? value.toString()
            : value.toString().toLowerCase();

          const strategy = config.strategy || 'includes';
          const weight = config.weight || 1;

          let matches = false;

          switch (strategy) {
            case 'exact':
              matches = fieldValue === search;
              break;
            case 'startsWith':
              matches = fieldValue.startsWith(search);
              break;
            case 'includes':
            default:
              matches = fieldValue.includes(search);
              break;
          }

          if (matches) {
            score += weight;
          }
        }

        return { item, score };
      })
      .filter((result) => result.score > 0)
      .sort((a, b) => b.score - a.score) // Highest score first
      .map((result) => result.item);
  }, [items, searchTerm, searchFields, caseSensitive]);

  return {
    searchTerm,
    setSearchTerm,
    filteredItems,
  };
}

// Usage with weighted fields
interface Product {
  id: number;
  name: string;
  description: string;
  sku: string;
}

function ProductSearch({ products }: { products: Product[] }) {
  const { searchTerm, setSearchTerm, filteredItems } = useAdvancedSearch({
    items: products,
    searchFields: [
      { field: 'sku', strategy: 'exact', weight: 10 },      // Exact SKU match = highest priority
      { field: 'name', strategy: 'startsWith', weight: 5 }, // Name starts with = high priority
      { field: 'name', strategy: 'includes', weight: 2 },   // Name contains = medium priority
      { field: 'description', strategy: 'includes', weight: 1 }, // Description = lowest priority
    ],
  });

  return (
    <div>
      <input
        type="search"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search products by name, SKU, or description..."
      />

      <ul>
        {filteredItems.map((product) => (
          <li key={product.id}>
            <strong>{product.name}</strong> - {product.sku}
            <p>{product.description}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

# JavaScript Version

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

function useAdvancedSearch({
  items,
  searchFields,
  caseSensitive = false,
}) {
  const [searchTerm, setSearchTerm] = useState('');

  const filteredItems = useMemo(() => {
    if (!searchTerm) return items;

    const search = caseSensitive 
      ? searchTerm 
      : searchTerm.toLowerCase();

    return items
      .map((item) => {
        let score = 0;

        for (const config of searchFields) {
          const value = item[config.field];
          if (typeof value !== 'string' && typeof value !== 'number') {
            continue;
          }

          const fieldValue = caseSensitive
            ? value.toString()
            : value.toString().toLowerCase();

          const strategy = config.strategy || 'includes';
          const weight = config.weight || 1;

          let matches = false;

          switch (strategy) {
            case 'exact':
              matches = fieldValue === search;
              break;
            case 'startsWith':
              matches = fieldValue.startsWith(search);
              break;
            case 'includes':
            default:
              matches = fieldValue.includes(search);
              break;
          }

          if (matches) {
            score += weight;
          }
        }

        return { item, score };
      })
      .filter((result) => result.score > 0)
      .sort((a, b) => b.score - a.score)
      .map((result) => result.item);
  }, [items, searchTerm, searchFields, caseSensitive]);

  return {
    searchTerm,
    setSearchTerm,
    filteredItems,
  };
}

Weighted search benefits:

  • Prioritize exact matches over partial
  • SKU/ID exact match appears first
  • Name matches rank higher than description
  • Customizable relevance scoring

# Advanced Filtering Options

Production apps often need additional filters beyond search:

# TypeScript Version

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

interface FilterOptions {
  [key: string]: any;
}

interface UseSearchWithFiltersProps<T> {
  items: T[];
  searchFields: (keyof T)[];
}

interface UseSearchWithFiltersReturn<T> {
  searchTerm: string;
  setSearchTerm: (term: string) => void;
  filters: FilterOptions;
  setFilters: (filters: FilterOptions) => void;
  setFilter: (key: string, value: any) => void;
  clearFilters: () => void;
  filteredItems: T[];
  totalResults: number;
}

function useSearchWithFilters<T extends Record<string, any>>({
  items,
  searchFields,
}: UseSearchWithFiltersProps<T>): UseSearchWithFiltersReturn<T> {
  const [searchTerm, setSearchTerm] = useState('');
  const [filters, setFiltersState] = useState<FilterOptions>({});

  const setFilters = (newFilters: FilterOptions) => {
    setFiltersState(newFilters);
  };

  const setFilter = (key: string, value: any) => {
    setFiltersState((prev) => ({
      ...prev,
      [key]: value,
    }));
  };

  const clearFilters = () => {
    setFiltersState({});
    setSearchTerm('');
  };

  const filteredItems = useMemo(() => {
    let result = items;

    // Apply search term filter
    if (searchTerm) {
      const lowerSearch = searchTerm.toLowerCase();
      result = result.filter((item) => {
        return searchFields.some((field) => {
          const value = item[field];
          return typeof value === 'string'
            ? value.toLowerCase().includes(lowerSearch)
            : false;
        });
      });
    }

    // Apply additional filters
    Object.entries(filters).forEach(([key, value]) => {
      if (value === null || value === undefined || value === '') return;

      result = result.filter((item) => {
        const itemValue = item[key];

        // Handle array filters (e.g., multiple selections)
        if (Array.isArray(value)) {
          return value.includes(itemValue);
        }

        // Handle range filters (e.g., min-max)
        if (
          typeof value === 'object' &&
          'min' in value &&
          'max' in value
        ) {
          const numValue = typeof itemValue === 'number' 
            ? itemValue 
            : parseFloat(itemValue);
          return numValue >= value.min && numValue <= value.max;
        }

        // Handle boolean filters
        if (typeof value === 'boolean') {
          return itemValue === value;
        }

        // Handle exact match
        return itemValue === value;
      });
    });

    return result;
  }, [items, searchTerm, searchFields, filters]);

  return {
    searchTerm,
    setSearchTerm,
    filters,
    setFilters,
    setFilter,
    clearFilters,
    filteredItems,
    totalResults: filteredItems.length,
  };
}

// Usage
interface Product {
  id: number;
  name: string;
  category: string;
  price: number;
  inStock: boolean;
}

function ProductFilter({ products }: { products: Product[] }) {
  const {
    searchTerm,
    setSearchTerm,
    filters,
    setFilter,
    clearFilters,
    filteredItems,
    totalResults,
  } = useSearchWithFilters({
    items: products,
    searchFields: ['name'],
  });

  return (
    <div>
      {/* Search */}
      <input
        type="search"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search products..."
      />

      {/* Category filter */}
      <select
        value={filters.category || ''}
        onChange={(e) => setFilter('category', e.target.value || null)}
      >
        <option value="">All Categories</option>
        <option value="Electronics">Electronics</option>
        <option value="Clothing">Clothing</option>
        <option value="Books">Books</option>
      </select>

      {/* Price range filter */}
      <div>
        <input
          type="number"
          placeholder="Min price"
          onChange={(e) =>
            setFilter('price', {
              min: parseFloat(e.target.value) || 0,
              max: filters.price?.max || Infinity,
            })
          }
        />
        <input
          type="number"
          placeholder="Max price"
          onChange={(e) =>
            setFilter('price', {
              min: filters.price?.min || 0,
              max: parseFloat(e.target.value) || Infinity,
            })
          }
        />
      </div>

      {/* Stock filter */}
      <label>
        <input
          type="checkbox"
          checked={filters.inStock === true}
          onChange={(e) =>
            setFilter('inStock', e.target.checked ? true : null)
          }
        />
        In Stock Only
      </label>

      <button onClick={clearFilters}>Clear All Filters</button>

      <p>Showing {totalResults} results</p>

      <ul>
        {filteredItems.map((product) => (
          <li key={product.id}>
            {product.name} - ${product.price}
            {!product.inStock && ' (Out of Stock)'}
          </li>
        ))}
      </ul>
    </div>
  );
}

# JavaScript Version

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

function useSearchWithFilters({ items, searchFields }) {
  const [searchTerm, setSearchTerm] = useState('');
  const [filters, setFiltersState] = useState({});

  const setFilters = (newFilters) => {
    setFiltersState(newFilters);
  };

  const setFilter = (key, value) => {
    setFiltersState((prev) => ({
      ...prev,
      [key]: value,
    }));
  };

  const clearFilters = () => {
    setFiltersState({});
    setSearchTerm('');
  };

  const filteredItems = useMemo(() => {
    let result = items;

    if (searchTerm) {
      const lowerSearch = searchTerm.toLowerCase();
      result = result.filter((item) => {
        return searchFields.some((field) => {
          const value = item[field];
          return typeof value === 'string'
            ? value.toLowerCase().includes(lowerSearch)
            : false;
        });
      });
    }

    Object.entries(filters).forEach(([key, value]) => {
      if (value === null || value === undefined || value === '') return;

      result = result.filter((item) => {
        const itemValue = item[key];

        if (Array.isArray(value)) {
          return value.includes(itemValue);
        }

        if (
          typeof value === 'object' &&
          'min' in value &&
          'max' in value
        ) {
          const numValue = typeof itemValue === 'number' 
            ? itemValue 
            : parseFloat(itemValue);
          return numValue >= value.min && numValue <= value.max;
        }

        if (typeof value === 'boolean') {
          return itemValue === value;
        }

        return itemValue === value;
      });
    });

    return result;
  }, [items, searchTerm, searchFields, filters]);

  return {
    searchTerm,
    setSearchTerm,
    filters,
    setFilters,
    setFilter,
    clearFilters,
    filteredItems,
    totalResults: filteredItems.length,
  };
}

Filter types supported:

  1. Exact match: category === "Electronics"
  2. Array match: category in ["Electronics", "Books"]
  3. Range: price >= 10 && price <= 100
  4. Boolean: inStock === true

# Fuzzy Matching Implementation

Fuzzy matching finds items even with typos or misspellings:

# TypeScript Version

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

// Calculate Levenshtein distance (edit distance)
function levenshteinDistance(str1: string, str2: string): number {
  const len1 = str1.length;
  const len2 = str2.length;
  const matrix: number[][] = [];

  for (let i = 0; i <= len1; i++) {
    matrix[i] = [i];
  }

  for (let j = 0; j <= len2; j++) {
    matrix[0][j] = j;
  }

  for (let i = 1; i <= len1; i++) {
    for (let j = 1; j <= len2; j++) {
      const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
      matrix[i][j] = Math.min(
        matrix[i - 1][j] + 1,        // deletion
        matrix[i][j - 1] + 1,        // insertion
        matrix[i - 1][j - 1] + cost  // substitution
      );
    }
  }

  return matrix[len1][len2];
}

interface UseFuzzySearchProps<T> {
  items: T[];
  searchFields: (keyof T)[];
  threshold?: number; // Max edit distance (0-1, where 0 = exact match)
}

function useFuzzySearch<T>({
  items,
  searchFields,
  threshold = 0.3,
}: UseFuzzySearchProps<T>) {
  const [searchTerm, setSearchTerm] = useState('');

  const filteredItems = useMemo(() => {
    if (!searchTerm) return items;

    const lowerSearch = searchTerm.toLowerCase();
    const maxDistance = Math.floor(searchTerm.length * threshold);

    return items
      .map((item) => {
        let minDistance = Infinity;

        for (const field of searchFields) {
          const value = item[field];
          if (typeof value !== 'string') continue;

          const lowerValue = value.toLowerCase();

          // Check exact substring match first (fastest)
          if (lowerValue.includes(lowerSearch)) {
            return { item, distance: 0 };
          }

          // Check fuzzy match on whole string
          const distance = levenshteinDistance(lowerSearch, lowerValue);
          minDistance = Math.min(minDistance, distance);

          // Also check fuzzy match on words
          const words = lowerValue.split(/\s+/);
          for (const word of words) {
            const wordDistance = levenshteinDistance(lowerSearch, word);
            minDistance = Math.min(minDistance, wordDistance);
          }
        }

        return { item, distance: minDistance };
      })
      .filter((result) => result.distance <= maxDistance)
      .sort((a, b) => a.distance - b.distance) // Closest matches first
      .map((result) => result.item);
  }, [items, searchTerm, searchFields, threshold]);

  return {
    searchTerm,
    setSearchTerm,
    filteredItems,
  };
}

// Usage
function UserFuzzySearch({ users }: { users: User[] }) {
  const { searchTerm, setSearchTerm, filteredItems } = useFuzzySearch({
    items: users,
    searchFields: ['name', 'email'],
    threshold: 0.4, // Allow up to 40% character difference
  });

  return (
    <div>
      <input
        type="search"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search (typo-tolerant)..."
      />
      <p>
        {filteredItems.length > 0 
          ? `Found ${filteredItems.length} matches` 
          : 'No matches found'}
      </p>

      <ul>
        {filteredItems.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

# JavaScript Version

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

function levenshteinDistance(str1, str2) {
  const len1 = str1.length;
  const len2 = str2.length;
  const matrix = [];

  for (let i = 0; i <= len1; i++) {
    matrix[i] = [i];
  }

  for (let j = 0; j <= len2; j++) {
    matrix[0][j] = j;
  }

  for (let i = 1; i <= len1; i++) {
    for (let j = 1; j <= len2; j++) {
      const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
      matrix[i][j] = Math.min(
        matrix[i - 1][j] + 1,
        matrix[i][j - 1] + 1,
        matrix[i - 1][j - 1] + cost
      );
    }
  }

  return matrix[len1][len2];
}

function useFuzzySearch({
  items,
  searchFields,
  threshold = 0.3,
}) {
  const [searchTerm, setSearchTerm] = useState('');

  const filteredItems = useMemo(() => {
    if (!searchTerm) return items;

    const lowerSearch = searchTerm.toLowerCase();
    const maxDistance = Math.floor(searchTerm.length * threshold);

    return items
      .map((item) => {
        let minDistance = Infinity;

        for (const field of searchFields) {
          const value = item[field];
          if (typeof value !== 'string') continue;

          const lowerValue = value.toLowerCase();

          if (lowerValue.includes(lowerSearch)) {
            return { item, distance: 0 };
          }

          const distance = levenshteinDistance(lowerSearch, lowerValue);
          minDistance = Math.min(minDistance, distance);

          const words = lowerValue.split(/\s+/);
          for (const word of words) {
            const wordDistance = levenshteinDistance(lowerSearch, word);
            minDistance = Math.min(minDistance, wordDistance);
          }
        }

        return { item, distance: minDistance };
      })
      .filter((result) => result.distance <= maxDistance)
      .sort((a, b) => a.distance - b.distance)
      .map((result) => result.item);
  }, [items, searchTerm, searchFields, threshold]);

  return {
    searchTerm,
    setSearchTerm,
    filteredItems,
  };
}

Fuzzy match examples:

  • Search "Jon" finds "John", "Joan", "Jonathan"
  • Search "iphone" finds "iPhone", "Iphone 14"
  • Search "email" finds "e-mail", "Email"

Performance note: Levenshtein distance is O(n*m). For large datasets, use it with debouncing or limit to first N results.

# Highlight Search Terms

Show users what matched by highlighting search terms:

# TypeScript Version

typescript
function highlightText(text: string, searchTerm: string): JSX.Element {
  if (!searchTerm) return <>{text}</>;

  const regex = new RegExp(`(${searchTerm})`, 'gi');
  const parts = text.split(regex);

  return (
    <>
      {parts.map((part, index) =>
        regex.test(part) ? (
          <mark key={index} style={{ backgroundColor: '#ffeb3b' }}>
            {part}
          </mark>
        ) : (
          <span key={index}>{part}</span>
        )
      )}
    </>
  );
}

// Usage
function SearchResult({ user, searchTerm }: { 
  user: User; 
  searchTerm: string;
}) {
  return (
    <li>
      <div>{highlightText(user.name, searchTerm)}</div>
      <div>{highlightText(user.email, searchTerm)}</div>
    </li>
  );
}

# Server-Side Search Integration

For large datasets, search on server instead of client:

# TypeScript Version

typescript
import { useState, useEffect, useRef } from 'react';

interface UseServerSearchProps {
  searchFn: (term: string, signal: AbortSignal) => Promise<any[]>;
  debounceMs?: number;
  minChars?: number;
}

function useServerSearch({
  searchFn,
  debounceMs = 300,
  minChars = 2,
}: UseServerSearchProps) {
  const [searchTerm, setSearchTerm] = useState('');
  const [results, setResults] = useState<any[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const abortControllerRef = useRef<AbortController | null>(null);

  useEffect(() => {
    if (searchTerm.length < minChars) {
      setResults([]);
      return;
    }

    const timerId = setTimeout(() => {
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }

      abortControllerRef.current = new AbortController();
      setLoading(true);
      setError(null);

      searchFn(searchTerm, abortControllerRef.current.signal)
        .then((data) => {
          setResults(data);
          setLoading(false);
        })
        .catch((err) => {
          if (err.name !== 'AbortError') {
            setError(err.message);
            setLoading(false);
          }
        });
    }, debounceMs);

    return () => {
      clearTimeout(timerId);
      if (abortControllerRef.current) {
        abortControllerRef.current.abort();
      }
    };
  }, [searchTerm, searchFn, debounceMs, minChars]);

  return {
    searchTerm,
    setSearchTerm,
    results,
    loading,
    error,
  };
}

// Usage
async function searchUsers(term: string, signal: AbortSignal) {
  const response = await fetch(
    `https://api.example.com/users/search?q=${encodeURIComponent(term)}`,
    { signal }
  );
  if (!response.ok) throw new Error('Search failed');
  return response.json();
}

function ServerUserSearch() {
  const { searchTerm, setSearchTerm, results, loading, error } = useServerSearch({
    searchFn: searchUsers,
    debounceMs: 500,
    minChars: 3,
  });

  return (
    <div>
      <input
        type="search"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search (min 3 characters)..."
      />

      {loading && <div>Searching...</div>}
      {error && <div>Error: {error}</div>}

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

# Complete Hook: useSearch

Production-ready hook combining all features:

# TypeScript Version

typescript
import { useState, useMemo, useEffect, useRef } from 'react';

interface SearchFieldConfig<T> {
  field: keyof T;
  weight?: number;
}

interface UseSearchOptions<T> {
  items: T[];
  searchFields: SearchFieldConfig<T>[];
  debounceMs?: number;
  caseSensitive?: boolean;
  fuzzy?: boolean;
  fuzzyThreshold?: number;
}

interface UseSearchReturn<T> {
  searchTerm: string;
  setSearchTerm: (term: string) => void;
  debouncedTerm: string;
  filteredItems: T[];
  isSearching: boolean;
  totalResults: number;
}

function useSearch<T>({
  items,
  searchFields,
  debounceMs = 300,
  caseSensitive = false,
  fuzzy = false,
  fuzzyThreshold = 0.3,
}: UseSearchOptions<T>): UseSearchReturn<T> {
  const [searchTerm, setSearchTerm] = useState('');
  const [debouncedTerm, setDebouncedTerm] = useState('');

  // Debounce search term
  useEffect(() => {
    const timerId = setTimeout(() => {
      setDebouncedTerm(searchTerm);
    }, debounceMs);

    return () => clearTimeout(timerId);
  }, [searchTerm, debounceMs]);

  // Filter items
  const filteredItems = useMemo(() => {
    if (!debouncedTerm) return items;

    const search = caseSensitive 
      ? debouncedTerm 
      : debouncedTerm.toLowerCase();

    if (fuzzy) {
      const maxDistance = Math.floor(search.length * fuzzyThreshold);

      return items
        .map((item) => {
          let minDistance = Infinity;

          for (const config of searchFields) {
            const value = item[config.field];
            if (typeof value !== 'string') continue;

            const fieldValue = caseSensitive 
              ? value 
              : value.toLowerCase();

            if (fieldValue.includes(search)) {
              minDistance = 0;
              break;
            }

            const distance = levenshteinDistance(search, fieldValue);
            minDistance = Math.min(minDistance, distance);
          }

          return { item, distance: minDistance };
        })
        .filter((result) => result.distance <= maxDistance)
        .sort((a, b) => a.distance - b.distance)
        .map((result) => result.item);
    }

    return items
      .map((item) => {
        let score = 0;

        for (const config of searchFields) {
          const value = item[config.field];
          if (typeof value !== 'string' && typeof value !== 'number') {
            continue;
          }

          const fieldValue = caseSensitive
            ? value.toString()
            : value.toString().toLowerCase();

          if (fieldValue.includes(search)) {
            score += config.weight || 1;
          }
        }

        return { item, score };
      })
      .filter((result) => result.score > 0)
      .sort((a, b) => b.score - a.score)
      .map((result) => result.item);
  }, [items, debouncedTerm, searchFields, caseSensitive, fuzzy, fuzzyThreshold]);

  return {
    searchTerm,
    setSearchTerm,
    debouncedTerm,
    filteredItems,
    isSearching: searchTerm !== debouncedTerm,
    totalResults: filteredItems.length,
  };
}

function levenshteinDistance(str1: string, str2: string): number {
  const len1 = str1.length;
  const len2 = str2.length;
  const matrix: number[][] = [];

  for (let i = 0; i <= len1; i++) {
    matrix[i] = [i];
  }

  for (let j = 0; j <= len2; j++) {
    matrix[0][j] = j;
  }

  for (let i = 1; i <= len1; i++) {
    for (let j = 1; j <= len2; j++) {
      const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
      matrix[i][j] = Math.min(
        matrix[i - 1][j] + 1,
        matrix[i][j - 1] + 1,
        matrix[i - 1][j - 1] + cost
      );
    }
  }

  return matrix[len1][len2];
}

export default useSearch;

# JavaScript Version

javascript
import { useState, useMemo, useEffect } from 'react';

function useSearch({
  items,
  searchFields,
  debounceMs = 300,
  caseSensitive = false,
  fuzzy = false,
  fuzzyThreshold = 0.3,
}) {
  const [searchTerm, setSearchTerm] = useState('');
  const [debouncedTerm, setDebouncedTerm] = useState('');

  useEffect(() => {
    const timerId = setTimeout(() => {
      setDebouncedTerm(searchTerm);
    }, debounceMs);

    return () => clearTimeout(timerId);
  }, [searchTerm, debounceMs]);

  const filteredItems = useMemo(() => {
    if (!debouncedTerm) return items;

    const search = caseSensitive 
      ? debouncedTerm 
      : debouncedTerm.toLowerCase();

    if (fuzzy) {
      const maxDistance = Math.floor(search.length * fuzzyThreshold);

      return items
        .map((item) => {
          let minDistance = Infinity;

          for (const config of searchFields) {
            const value = item[config.field];
            if (typeof value !== 'string') continue;

            const fieldValue = caseSensitive 
              ? value 
              : value.toLowerCase();

            if (fieldValue.includes(search)) {
              minDistance = 0;
              break;
            }

            const distance = levenshteinDistance(search, fieldValue);
            minDistance = Math.min(minDistance, distance);
          }

          return { item, distance: minDistance };
        })
        .filter((result) => result.distance <= maxDistance)
        .sort((a, b) => a.distance - b.distance)
        .map((result) => result.item);
    }

    return items
      .map((item) => {
        let score = 0;

        for (const config of searchFields) {
          const value = item[config.field];
          if (typeof value !== 'string' && typeof value !== 'number') {
            continue;
          }

          const fieldValue = caseSensitive
            ? value.toString()
            : value.toString().toLowerCase();

          if (fieldValue.includes(search)) {
            score += config.weight || 1;
          }
        }

        return { item, score };
      })
      .filter((result) => result.score > 0)
      .sort((a, b) => b.score - a.score)
      .map((result) => result.item);
  }, [items, debouncedTerm, searchFields, caseSensitive, fuzzy, fuzzyThreshold]);

  return {
    searchTerm,
    setSearchTerm,
    debouncedTerm,
    filteredItems,
    isSearching: searchTerm !== debouncedTerm,
    totalResults: filteredItems.length,
  };
}

function levenshteinDistance(str1, str2) {
  const len1 = str1.length;
  const len2 = str2.length;
  const matrix = [];

  for (let i = 0; i <= len1; i++) {
    matrix[i] = [i];
  }

  for (let j = 0; j <= len2; j++) {
    matrix[0][j] = j;
  }

  for (let i = 1; i <= len1; i++) {
    for (let j = 1; j <= len2; j++) {
      const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
      matrix[i][j] = Math.min(
        matrix[i - 1][j] + 1,
        matrix[i][j - 1] + 1,
        matrix[i - 1][j - 1] + cost
      );
    }
  }

  return matrix[len1][len2];
}

export default useSearch;

Real-world implementation with highlighting and fuzzy matching:

# TypeScript Version

typescript
import useSearch from './hooks/useSearch';

interface GitHubUser {
  id: number;
  login: string;
  name: string;
  bio: string;
  avatar_url: string;
  html_url: string;
}

function highlightMatch(text: string, searchTerm: string): JSX.Element {
  if (!searchTerm || !text) return <>{text}</>;

  const regex = new RegExp(`(${searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\#x26;')})`, 'gi');
  const parts = text.split(regex);

  return (
    <>
      {parts.map((part, i) =>
        regex.test(part) ? (
          <mark key={i} style={{ backgroundColor: '#fff3cd', fontWeight: 'bold' }}>
            {part}
          </mark>
        ) : (
          <span key={i}>{part}</span>
        )
      )}
    </>
  );
}

function GitHubUserSearch({ users }: { users: GitHubUser[] }) {
  const {
    searchTerm,
    setSearchTerm,
    filteredItems,
    isSearching,
    totalResults,
  } = useSearch({
    items: users,
    searchFields: [
      { field: 'login', weight: 10 },
      { field: 'name', weight: 5 },
      { field: 'bio', weight: 1 },
    ],
    debounceMs: 300,
    fuzzy: true,
    fuzzyThreshold: 0.3,
  });

  return (
    <div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
      <h1>GitHub Users Search</h1>

      <div style={{ marginBottom: '20px' }}>
        <input
          type="search"
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          placeholder="Search by username, name, or bio..."
          style={{
            width: '100%',
            padding: '12px',
            fontSize: '16px',
            border: '2px solid #ddd',
            borderRadius: '8px',
          }}
        />
        {isSearching && (
          <div style={{ marginTop: '8px', color: '#666', fontSize: '14px' }}>
            Searching...
          </div>
        )}
      </div>

      {searchTerm && (
        <div style={{ marginBottom: '16px', color: '#666' }}>
          Found {totalResults} users
        </div>
      )}

      <div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
        {filteredItems.map((user) => (
          <div
            key={user.id}
            style={{
              display: 'flex',
              gap: '16px',
              padding: '16px',
              border: '1px solid #ddd',
              borderRadius: '8px',
              backgroundColor: 'white',
            }}
          >
            <img
              src={user.avatar_url}
              alt={user.login}
              style={{
                width: '64px',
                height: '64px',
                borderRadius: '50%',
              }}
            />
            <div style={{ flex: 1 }}>
              <h3 style={{ margin: '0 0 8px 0' }}>
                <a
                  href={user.html_url}
                  target="_blank"
                  rel="noopener noreferrer"
                  style={{ color: '#0969da', textDecoration: 'none' }}
                >
                  {highlightMatch(user.login, searchTerm)}
                </a>
              </h3>
              {user.name && (
                <div style={{ fontSize: '16px', marginBottom: '8px' }}>
                  {highlightMatch(user.name, searchTerm)}
                </div>
              )}
              {user.bio && (
                <p style={{ margin: 0, color: '#666', fontSize: '14px' }}>
                  {highlightMatch(user.bio, searchTerm)}
                </p>
              )}
            </div>
          </div>
        ))}
      </div>

      {filteredItems.length === 0 && searchTerm && !isSearching && (
        <div
          style={{
            textAlign: 'center',
            padding: '40px',
            color: '#666',
          }}
        >
          No users found matching "{searchTerm}"
        </div>
      )}
    </div>
  );
}

export default GitHubUserSearch;

# FAQ

A: It depends on your data size:

Use client-side when:

  • < 1,000 items
  • Data loads once
  • Instant feedback needed
  • Offline support required

Use server-side when:

  • 1,000 items

  • Data changes frequently
  • Need full-text search
  • Want to reduce client memory

Hybrid approach:

typescript
// Client-side for first 100 results, server for more
const useHybridSearch = ({ items, serverSearchFn }) => {
  const clientResults = useSearch({ items: items.slice(0, 100) });
  const serverResults = useServerSearch({ searchFn: serverSearchFn });
  
  return items.length <= 100 ? clientResults : serverResults;
};

# Q: What's the best debounce delay?

A: Depends on use case:

typescript
const delays = {
  // Instant feedback (autocomplete)
  autocomplete: 150,
  
  // Normal search
  standard: 300,
  
  // Server-side expensive operations
  server: 500,
  
  // Really slow APIs
  slow: 800,
};

Rule of thumb: Start with 300ms. Increase if server is slow, decrease if users complain about lag.

# Q: How do I prevent filtering during typing but show count?

A: Track two separate terms:

typescript
const [inputValue, setInputValue] = useState('');
const [searchTerm, setSearchTerm] = useState('');

useEffect(() => {
  const timerId = setTimeout(() => {
    setSearchTerm(inputValue); // This triggers actual filter
  }, 300);
  return () => clearTimeout(timerId);
}, [inputValue]);

return (
  <>
    <input 
      value={inputValue} 
      onChange={(e) => setInputValue(e.target.value)} 
    />
    <span>Typing: {inputValue}</span>
    <span>Results: {filteredItems.length}</span>
  </>
);

A: Escape regex special characters:

typescript
function escapeRegex(str: string): string {
  return str.replace(/[.*+?^${}()|[\]\\]/g, '\\#x26;');
}

const safeSearch = escapeRegex(searchTerm);
const regex = new RegExp(safeSearch, 'gi');

# Q: Can I search nested objects?

A: Yes, use dot notation or custom accessor:

typescript
interface Product {
  id: number;
  details: {
    name: string;
    manufacturer: string;
  };
}

// Option 1: Flatten before searching
const searchableProducts = products.map(p => ({
  ...p,
  name: p.details.name,
  manufacturer: p.details.manufacturer,
}));

// Option 2: Custom accessor function
interface SearchConfig<T> {
  accessor: (item: T) => string;
  weight?: number;
}

const { filteredItems } = useSearch({
  items: products,
  searchFields: [
    { accessor: (p) => p.details.name, weight: 5 },
    { accessor: (p) => p.details.manufacturer, weight: 2 },
  ],
});

# Q: How do I add search history?

A: Store recent searches in localStorage:

typescript
const useSearchWithHistory = ({ items, searchFields }) => {
  const [history, setHistory] = useState<string[]>(() => {
    const saved = localStorage.getItem('searchHistory');
    return saved ? JSON.parse(saved) : [];
  });

  const search = useSearch({ items, searchFields });

  const addToHistory = (term: string) => {
    if (!term) return;
    
    const newHistory = [
      term,
      ...history.filter(h => h !== term)
    ].slice(0, 10); // Keep last 10
    
    setHistory(newHistory);
    localStorage.setItem('searchHistory', JSON.stringify(newHistory));
  };

  return {
    ...search,
    history,
    addToHistory,
  };
};

Related Articles:

Questions? Share your search implementations! How do you handle large datasets? What debounce delays work best for your use case?

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT