CodeWithYou

Unlocking Advanced Search Functionality in React with a Custom useSearch Hook

Published on
Authors
"Unlocking Advanced Search Functionality in React with a Custom useSearch Hook"
Photo by AI

Unlocking Advanced Search Functionality in React with a Custom useSearch Hook

Recently, I found myself in need of a search feature for a React application. Like many developers, I turned to Google for guidance. The first article I clicked on was about creating a search and filter component, and it felt oddly familiar—it was one I had written years ago.

Looking back, I realized my understanding of search functionality in React had dramatically evolved. I learned that search can be so much more than simply matching strings; it can be powerful, flexible, and efficient. Consequently, I embarked on developing a reusable useSearch hook that can handle everything from large datasets to typo-tolerant searches, and I am thrilled to share it with you.

In this guide, I will walk you through each step of building this system. By the end, you’ll have a high-performance search solution that is adaptable to any React project, no matter how complex the data.

Table of Contents

  1. Introduction
  2. The Limitations of Basic Search Implementations
  3. Building the Reusable useSearch Hook
  4. Creating Search and Pagination Filters
  5. Handling Typos with Fuzzy Search
  6. Implementing the useSearch Hook
  7. Conclusion
  8. Audience

Introduction

If you are a React developer, whether you’re just starting or are a seasoned expert, you may have encountered the constraints of basic search features. Perhaps your searches lag with large datasets, struggle with misspellings, or fail to adapt to various data structures. If this sounds familiar, this guide will be invaluable to you. Together, we'll create a high-performance, reusable search system that works seamlessly in real-world applications.

The Limitations of Basic Search Implementations

Let’s kick things off by examining a basic search component:

function SimpleSearch() {
    const data = [
        { name: "JavaScript" },
        { name: "Python" },
        { name: "Java" }
    ];

    const [query, setQuery] = useState("");

    const results = data.filter((item) => item.name.includes(query));

    return (
        <div>
            <input
                type="text"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Search..."
            />
            <ul>
                {results.map((item, index) => (
                    <li key={index}>{item.name}</li>
                ))}
            </ul>
        </div>
    );
}

At first glance, it seems functional—you can type a query and receive results. However, in practical applications, searches need to accommodate much more than simple string matches. Here are some notable limitations of such an approach:

  • Limited data support: This method is only effective for plain strings and fails with more complex data structures.
  • Struggles with nested objects: For data with deeper structures (e.g., { user: { name: "JavaScript" } }), this simple implementation falls short.
  • No typo tolerance: Minor misspellings, like searching for "javascrpt" instead of "JavaScript," lead to user frustration as they yield no results.
  • Performance issues: Each keystroke triggers a complete re-render, causing lag in larger datasets.

Clearly, a more robust solution is necessary. Let's move toward building a superior search system that is flexible, optimized, and user-friendly.

Building the Reusable useSearch Hook

To address these issues, we will create a useSearch hook that:

  • Supports diverse data types, including strings, numbers, dates, and nested objects.
  • Enhances performance with techniques like debouncing and memoization.
  • Handles typos through fuzzy search capabilities.

Creating the Hook

Let’s kick off by setting up the core useSearch hook. It will take in the data, the search query, and any filter functions that should be applied:

// hooks/useSearch.js

function useSearch(data, query, ...filters) {
    const debouncedQuery = useDebounce(query, 300);

    return React.useMemo(() => {
        const dataArray = Array.isArray(data) ? data : [data];

        try {
            // Apply each filter function in sequence
            return filters.reduce(
                (acc, feature) => feature(acc, debouncedQuery),
                dataArray
            );
        } catch (error) {
            console.error("Error applying search features:", error);
            return dataArray;
        }
    }, [data, debouncedQuery, filters]);
}

Integrating Debouncing with useDebounce

Every keystroke triggers a new search if debouncing isn’t implemented. For instance, typing “apple” prompts searches for each letter (a, p, p, l, e), leading to multiple re-renders and performance degradation. Implement the following useDebounce hook to alleviate this issue:

import React from "react";

function useDebounce(value, delay) {
    const [debouncedValue, setDebouncedValue] = React.useState(value);

    React.useEffect(() => {
        const timeoutId = setTimeout(() => {
            setDebouncedValue(value);
        }, delay);

        return () => clearTimeout(timeoutId);
    }, [value, delay]);

    return debouncedValue;
}

This ensures that searches only execute after 300 milliseconds of inactivity, greatly improving responsiveness in your application.

Performance Optimization Using React.useMemo

Filtering large datasets can be resource-intensive. If your search logic runs on every component re-render—regardless of whether the search query changed—it can severely impact performance. Use React.useMemo as follows:

return React.useMemo(() => {
    // Filtering logic
}, [data, debouncedQuery, filters]);

With this implementation, the search only re-runs when the relevant parameters have changed, maintaining smooth performance even as unrelated state changes occur.

Using .reduce() to Chain Filters

The useSearch hook takes advantage of the .reduce() method to apply each filter function sequentially:

return filters.reduce(
    (acc, feature) => feature(acc, debouncedQuery),
    dataArray
);

This clean method allows easy addition or removal of filters as necessary.

Creating Search and Pagination Filters

Filters provide the magic that transforms our search hook by processing data based on user queries. We will create two types of filters for this tutorial: a search filter and a pagination filter.

The Search Filter

The search filter checks specific fields in an object for matches with the user’s input. It supports various matching strategies such as exact matches, starts-with, ends-with, or contains. Here’s how you can implement it:

// utils/search.js

export function search(options) {
    const { fields, matchType } = options;

    return (data, query) => {
        const trimmedQuery = String(query).trim().toLowerCase();

        if (!trimmedQuery) return data;

        return data.filter((item) => {
            const fieldsArray = fields
                ? Array.isArray(fields)
                    ? fields
                    : [fields]
                : getAllKeys(item);

            return fieldsArray.some((field) => {
                const fieldValue = getFieldValue(item, field);
                if (fieldValue == null) return false;

                const stringValue = convertToString(fieldValue).toLowerCase();

                switch (matchType) {
                    case "exact":
                        return stringValue === trimmedQuery;
                    case "startsWith":
                        return stringValue.startsWith(trimmedQuery);
                    case "endsWith":
                        return stringValue.endsWith(trimmedQuery);
                    case "contains":
                        return stringValue.includes(trimmedQuery);
                    default:
                        throw new Error(`Unsupported match type: ${matchType}`);
                }
            });
        });
    };
}

This function ensures a user-friendly search experience by normalizing inputs and thoroughly filtering data.

Helper Functions for Efficient Searching

To keep our filtering logic streamlined, we'll use several helpers to retrieve keys and values from objects:

  • getAllKeys: Gathers all keys even from nested structures.
  • getFieldValue: Extracts values from objects using a specified path.
  • convertToString: Standardizes various data types into strings.

The Pagination Filter

When dealing with large datasets, displaying all results simultaneously can overwhelm users. The pagination filter limits the number of results shown per query, enhancing performance and usability:

// utils/paginate.js

export function paginate(options) {
    const { page = 1, pageSize = 10 } = options;

    return (data, query) => {
        const startIndex = (page - 1) * pageSize;
        return data.slice(startIndex, startIndex + pageSize);
    };
}

This function effectively slices the data array, providing a manageable subset of results based on the current page.

Using the useSearch Hook

With the search and pagination filters defined, let’s see how to utilize them in a React component:

Example Component Implementation

First, import your custom useSearch hook and the filter functions:

import useSearch from "./hooks/useSearch.js";
import search from "./utils/search.js";
import paginate from "./utils/paginate.js";

Next, create a functional component that utilizes the filters:

function SearchComponent() {
    const data = [
        { name: "JavaScript" },
        { name: "Python" },
        { name: "Java" },
        { name: "Ruby" },
        // Imagine more data here
    ];

    const [query, setQuery] = React.useState("");
    const [page, setPage] = React.useState(1);
    const pageSize = 3; // Items per page

    const results = useSearch(
        data,
        query,
        search({
            fields: ["name"],
            matchType: "contains",
        }),
        paginate({ page, pageSize })
    );

    const filteredData = search({ fields: ["name"], matchType: "contains" })(data, query);
    const totalPages = Math.ceil(filteredData.length / pageSize);

    return (
        <div style={{ padding: "20px", fontFamily: "Arial, sans-serif" }}>
            <h2>Search and Pagination</h2>
            <input
                type="text"
                value={query}
                onChange={(e) => {
                    setQuery(e.target.value);
                    setPage(1); // Reset to first page on new search
                }}
                placeholder="Search by name..."
                style={{ padding: "8px", width: "300px", marginBottom: "10px" }}
            />
            <ul>
                {results.map((item, index) => (
                    <li key={index}>{item.name}</li>
                ))}
            </ul>
            <div style={{ marginTop: "10px" }}>
                <button
                    onClick={() => setPage((prev) => Math.max(prev - 1, 1))}
                    disabled={page === 1}
                    style={{ padding: "6px 12px", marginRight: "10px" }}
                >
                    Previous
                </button>
                <span>Page {page} of {totalPages}</span>
                <button
                    onClick={() => setPage((prev) => Math.min(prev + 1, totalPages))}
                    disabled={page >= totalPages}
                    style={{ padding: "6px 12px", marginLeft: "10px" }}
                >
                    Next
                </button>
            </div>
        </div>
    );
}

Now, you can fully utilize your custom hook for real-time searching and pagination!

Given that users often make typos, our search feature should compensate for small mistakes. To do this, we will be implementing a fuzzy search technique that utilizes an n-gram similarity algorithm. This algorithm breaks down words into smaller fragments (n-grams) and compares them to find matches.

Building N-Gram Similarity Algorithm

The n-gram algorithm operates by splitting search queries and dataset values into overlapping character sequences (n-grams) and measuring their similarities:

// utils/nGramFuzzySearch.js

export const nGramFuzzySearch = (value, query) => {
    const n = 2; // Default to bigrams (two-character sequences)
    const valueGrams = generateNGrams(value.toLowerCase(), n);
    const queryGrams = generateNGrams(query.toLowerCase(), n);
    const intersection = valueGrams.filter((gram) => queryGrams.includes(gram));

    return intersection.length / Math.max(valueGrams.length, queryGrams.length);
};

const generateNGrams = (str, n) => {
    const grams = [];

    for (let i = 0; i <= str.length - n; i++) {
        grams.push(str.slice(i, i + n));
    }

    return grams;
};

Integrating Fuzzy Search into the Search Filter

Update the search filter to include support for a new match type for fuzzy search:

import { nGramFuzzySearch } from "./nGramFuzzySearch";

export function search(options) {
    const { fields, matchType } = options;

    return (data, query) => {
        const trimmedQuery = String(query).trim().toLowerCase();
        if (trimmedQuery === "") {
            return data;
        }

        return data.filter((item) => {
            const fieldsArray = fields
                ? Array.isArray(fields)
                    ? fields
                    : [fields]
                : getAllKeys(item);

            return fieldsArray.some((field) => {
                const fieldValue = getFieldValue(item, field);
                if (fieldValue == null) return false;
                const stringValue = convertToString(fieldValue).toLowerCase();

                switch (matchType) {
                    case "exact":
                        return stringValue === trimmedQuery;
                    case "startsWith":
                        return stringValue.startsWith(trimmedQuery);
                    case "endsWith":
                        return stringValue.endsWith(trimmedQuery);
                    case "contains":
                        return stringValue.includes(trimmedQuery);
                    case "fuzzySearch": {
                        const threshold = 0.5;
                        const score = nGramFuzzySearch(stringValue, trimmedQuery);
                        return score >= threshold;
                    }
                    default:
                        throw new Error(`Unsupported match type: ${matchType}`);
                }
            });
        });
    };
}

With this setup, users can find relevant results even if they mistype, leading to a more satisfying search experience.

Using the Ready-Made useSearch Hook

For those who prefer not to start from scratch, there's a fully typed, optimized version of the useSearch hook available on npm, named use-search-react. This package simplifies search implementation, providing built-in support for sorting, pagination, grouping, and several fuzzy search algorithms, allowing you to focus on building your application instead of reinventing the wheel.

Installation and Component Usage

To install the hook, simply run:

npm install use-search-react

Using it in your component is straightforward:

import { useSearch, search } from "use-search-react";
import { useState } from "react";

function SearchComponent() {
    const [query, setQuery] = useState("");
    const data = [
        { name: "JavaScript" },
        { name: "Python" },
        { name: "Java" }
    ];

    const results = useSearch(
        data,
        query,
        search({
            fields: ["name"],
            matchType: "fuzzy",
        })
    );

    return (
        <div>
            <input
                type="text"
                value={query}
                onChange={(e) => setQuery(e.target.value)}
                placeholder="Search..."
            />
            <ul>
                {results.map((item, index) => (
                    <li key={index}>{item.name}</li>
                ))}
            </ul>
        </div>
    );
}

This example demonstrates how easily the hook can be integrated, even performing fuzzy searches on large datasets—efficiently managing tens of thousands of records.

Conclusion

Building a search system in React requires deliberate thought about crafting an intuitive and responsive User Experience. This article has guided you in creating a custom useSearch hook that can tackle common challenges, including performance issues, handling nested data, and accommodating user typos with fuzzy search options. Whether you choose to implement it from scratch or leverage the npm package, you now possess the skills to enhance your React projects with powerful search functionality.

Don't hesitate to explore and adapt this implementation to meet your unique needs. If you have any queries or need further assistance, feel free to reach out to me on Twitter @sprucekhalifa. Happy coding!

Advertisement