- Overview
- Installation
- Quick Start
- Core Features
- Advanced Usage
- API Reference
- Best Practices
- Troubleshooting
- TypeScript Support
- Performance Tips
- Testing
- Comparison with Alternatives
- FAQ
- Roadmap
- Contributing
- Changelog
- Community & Support
- License
useSearch
is a lightweight and flexible React hook that makes searching and filtering data a breeze. It’s built with a modular, pipeline-based approach, so you can add features as needed without worrying about breaking anything.
With useSearch
, you can:
- Quickly search through structured data.
- Apply filters without messing with custom operators.
- Easily extend it with features like ranking, sorting, and more.
Whether you're building an interactive search UI or just need a solid search tool, useSearch
is designed to keep things simple, fast, and scalable.
Key Features:
- Debounced search (customizable delay)
- Fuzzy search (Levenshtein, Jaro-Winkler, N-Gram, or custom algorithms)
-
Filtering with conditional logic (
>
,<
,contains
, etc.) - Sorting, pagination, and grouping
- Graceful error handling and warnings
- Optimized for large datasets
Getting started is simple! Install useSearch
using npm or yarn:
npm install use-search-react
# or
yarn add use-search-react
Once installed, you’re ready to plug it into your project.
Search an array of objects with debouncing (default: 150ms):
import { useSearch, search } from "use-search-react";
const ProductList = ({ products }) => {
const [query, setQuery] = useState("");
const results = useSearch(
products,
query,
search({
fields: ["name", "description"], // Search these fields
match: "contains", // Strategy: "contains", "exact", "fuzzy"
})
);
return (
<>
<input onChange={(e) => setQuery(e.target.value)} />
{results.map((product) => (
<ProductCard key={product.id} {...product} />
))}
</>
);
};
Control how frequently search updates occur:
useSearch(
data,
query,
300, // Debounce for 300ms
search({ ... })
);
Fuzzy search helps find results that are close but not exact matches, making it useful for handling typos, variations in spelling, and user input flexibility. This library includes three built-in fuzzy search algorithms, and you can also provide your own custom algorithm.
Each algorithm has different strengths and is suited for specific situations:
Algorithm | Best for... | Notes |
---|---|---|
Levenshtein Distance | Handling typos and small edits (e.g., "color" vs. "colour") | Measures how many insertions, deletions, or substitutions are needed to match words. |
Jaro-Winkler | Matching short names, fuzzy user searches, and correcting transpositions (e.g., "Brian" vs. "Brain") | Gives extra weight to common prefixes. |
N-Gram Similarity | Partial word matching and autocomplete-like suggestions (e.g., "prog" matching "programming") | Breaks words into overlapping chunks and compares them. |
The Levenshtein Distance algorithm measures the number of insertions, deletions, or substitutions needed to transform one word into another. The fewer edits required, the more similar the words are.
✅ Best for: Correcting typos and detecting near matches.
🔹 Example:
-
"hello"
→"helo"
→ 1 edit (deletion) -
"kitten"
→"sitting"
→ 3 edits (substitutions, insertions)
How it works:
- Convert both the value and query to lowercase.
- Compute the Levenshtein distance.
- Normalize the score to a range between
0
and1
, where1
means an exact match.
export const levenshteinFuzzySearch = (
value: string,
query: string
): number => {
const valueLength = value.length;
const queryLength = query.length;
if (queryLength === 0) return 1; // Empty query matches everything
const distance = levenshteinDistance(
value.toLowerCase(),
query.toLowerCase()
);
const maxDistance = Math.max(valueLength, queryLength);
return 1 - distance / maxDistance;
};
📌 When to use:
- Handling user typos (
"accommodation"
vs."acomodation"
) - Searching for product names with minor spelling errors
The Jaro-Winkler algorithm improves upon Jaro Distance by giving more weight to words that start with the same characters. It’s useful for detecting common misspellings, transpositions, and short name variations.
✅ Best for: Matching names and short words where small mistakes happen often.
🔹 Example:
-
"marhta"
→"martha"
(letters swapped, but still very similar) -
"Dwayne"
→"Duane"
(similar sound and prefix)
How it works:
- Finds matching characters within a certain range.
- Counts transpositions (swapped letters).
- Gives extra weight if the words share a common prefix.
export const jaroWinklerFuzzySearch = (
value: string,
query: string
): number => {
return jaroWinklerDistance(value.toLowerCase(), query.toLowerCase());
};
📌 When to use:
- Matching people’s names (
"Johnathan"
vs."Jonathan"
) - Handling swapped letters (
"Brian"
vs."Brain"
)
The N-Gram approach breaks words into overlapping letter groups (n-grams) and compares the overlap between two words. It’s useful for autocomplete features and partial matches.
✅ Best for: Matching partial words and autocomplete suggestions.
🔹 Example:
Using bigrams (2-letter n-grams):
-
"hello"
→["he", "el", "ll", "lo"]
-
"help"
→["he", "el", "lp"]
How it works:
- Convert both value and query to lowercase.
- Split words into n-grams (default: bigrams).
- Count how many n-grams overlap.
- Compute a similarity score between
0
and1
.
export const nGramFuzzySearch = (value: string, query: string): number => {
const n = 2; // Bigram by default
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);
};
📌 When to use:
-
Autocomplete search (e.g.,
"pro"
matching"programming"
) -
Matching partial words (e.g.,
"photo"
matching"photography"
)
If none of these algorithms fit your needs, you can provide your own custom fuzzy search function. Your function should take two strings (value
and query
) and return a similarity score between 0 and 1.
🔹 Example Custom Function:
const customFuzzySearch = (value: string, query: string): number => {
// Example: simple case-insensitive substring match
return value.toLowerCase().includes(query.toLowerCase()) ? 1 : 0;
};
To use your custom function, pass it to the search system like this:
useSearch(data, query, { fuzzySearch: customFuzzySearch });
Each fuzzy search algorithm has its strengths:
- Levenshtein Distance → Best for fixing typos and small edits.
- Jaro-Winkler Distance → Best for matching names and detecting transpositions.
- N-Gram Similarity → Best for autocomplete and partial matches.
You can choose the right algorithm based on your use case, or even provide your own if needed! 🚀
Apply conditional logic to refine results:
import { filter } from "use-search-react";
filter({
conditions: [
{ field: "price", operator: "lessThan", value: 100 },
{ field: "category", operator: "equals", value: "electronics" },
],
missingFieldBehavior: "exclude", // Handle missing fields
});
Combine multiple features:
useSearch(
data,
query,
search({ ... }),
sort({ field: 'price', order: 'asc' }),
paginate({ pageSize: 10, page: 2 })
);
Define your own matching function:
search({
match: (field, value, query) => {
// Custom logic (e.g., regex, external API)
return field === "email" ? value.endsWith(query) : false;
},
});
Search nested fields using dot notation:
search({
fields: ["user.profile.name", "metadata.tags[0]"],
});
The hook gracefully handles edge cases:
- Invalid
data
→ Returns empty array + console warning - Non-string
query
→ Throws error - Missing features → Returns original data
The useSearch
hook is your go-to solution for searching, filtering, sorting, grouping, and paginating data effortlessly.
function useSearch<T>(
data: T | T[],
query: string,
debounceTime?: number,
...features: SearchFeature<T>[]
): T[];
Parameter | Type | Description |
---|---|---|
data |
T | T[]
|
The data you want to search (single object or an array). |
query |
string |
The search term entered by the user. |
debounceTime |
number |
Debounce time (in milliseconds) |
features |
SearchFeature<T>[] |
Additional search, filter, sort, group, or pagination functions. |
Fine-tune how the search works with custom match strategies, case sensitivity, and field selection.
Option | Type | Default | Description |
---|---|---|---|
match |
"contains" | "startsWith" | "exact" | fn
|
"contains" |
Defines how search terms match data. Use a built-in strategy or a custom function. |
fields |
string[] |
All keys |
Specifies which fields to search within. |
caseSensitive |
boolean |
false |
Whether the search should distinguish between uppercase and lowercase letters. |
objectToStringThreshold |
number |
10 |
Converts objects with more than this number of nested keys into strings for searching. |
Narrow down search results by applying conditions like “greater than,” “equals,” or “contains.”
Option | Type | Default | Description |
---|---|---|---|
conditions |
Condition[] |
Required | An array of conditions that specify filtering rules (field , operator , value ). |
caseSensitive |
boolean |
false |
Determines if comparisons should be case-sensitive. |
missingFieldBehavior |
"skip" | "exclude" | "throw"
|
"skip" |
Defines what happens when a field is missing. Skip it, exclude the item, or throw an error. |
Sorts your data based on a specific field, either in ascending or descending order.
Option | Type | Default | Description |
---|---|---|---|
field |
string |
Required | The field used for sorting. |
order |
"asc" | "desc"
|
"asc" |
The order of sorting: ascending ("asc" ) or descending ("desc" ). |
nullsFirst |
boolean |
false |
Whether to place null or undefined values first. |
Organizes data into groups based on a specific field. If a field is missing, items are placed under a default group.
Option | Type | Default | Description |
---|---|---|---|
field |
string |
Required | The field used for grouping data. |
defaultGroupKey |
string |
"Other" |
The default group name for items missing the specified field. |
Limits the number of items displayed at a time and allows users to navigate between pages.
Option | Type | Default | Description |
---|---|---|---|
pageSize |
number |
10 |
Number of items per page. |
page |
number |
1 |
The current page number. |
In React, every time a component re-renders, it recreates arrays and objects unless they are memoized. This means that even if two arrays have the same content, React will treat them as different because they have new memory references.
For example, if you pass an array of features like this:
const data = [...]; // Some array of items
const results = useSearch(data, query, [
search({ fields: ["name"] }),
filter({ conditions: [...] }),
]);
React will recreate the array every time the component re-renders. Since useSearch
sees a new array reference, it treats it as a different input, potentially triggering unnecessary recalculations.
To prevent this, wrap the features in useMemo()
, so they are only re-created when needed:
const features = useMemo(() => [
search({ fields: ["name"] }),
filter({ conditions: [...] }),
], []);
const results = useSearch(data, query, ...features);
This way, features
remains stable across renders, avoiding unnecessary updates.
If you are passing a static array as data
, React may recreate the array on every render, even if the contents haven’t changed. This can also cause unnecessary recomputation inside useSearch
.
const results = useSearch(
[
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
query,
...features
);
Every render creates a new array reference, even though the contents are the same!
Instead, memoize the data array:
const data = useMemo(
() => [
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
],
[]
);
const results = useSearch(data, query, ...features);
If your data is dynamic (e.g., fetched from an API), use useState
:
const [data, setData] = useState([]);
useEffect(() => {
fetchData().then(setData);
}, []);
This ensures that data
is only updated when new data is fetched rather than being re-created every render.
When working with large datasets, you should:
-
Use
paginate()
to limit the number of items displayed at a time. -
Avoid expensive logic inside custom
match
functions to keep searches fast.
Adding a debounce prevents rapid re-renders when users type quickly. Choose a debounce time based on your dataset size:
-
Small datasets:
150ms
-
Large datasets:
300-500ms
Use useSearch
with a debounce like this:
const results = useSearch(data, query, 300, ...features);
This ensures that the search only updates after the user stops typing for 300ms, improving performance.
- Ensure
data
is an array. - Check
fields
insearch()
match your data structure. - Verify
query
is a string.
- Use
paginate()
to reduce DOM nodes. - Simplify nested
fields
paths.
useSearch
is fully typed and supports generics, ensuring seamless TypeScript integration with strong type inference. Using generics allows you to define the structure of your data, providing better IntelliSense and preventing runtime errors.
interface Product {
id: string;
name: string;
price: number;
category: string;
}
const results = useSearch<Product>(
products,
query,
search({ fields: ["name", "category"] })
);
By specifying <Product>
, TypeScript ensures that results
is strictly an array of Product
objects, improving developer experience and preventing common mistakes.
If you're using additional search features such as filters or sorting, TypeScript will enforce correct property access.
const features = [
search({ fields: ["name", "category"] }),
filter({ category: "Electronics" }),
];
const results = useSearch<Product>(products, query, ...features);
This prevents typos in field names and ensures that only valid properties of Product
are used.
When working with large datasets, optimizing search operations is crucial. Below are some best practices to keep useSearch
efficient and responsive:
Limiting the number of rendered items helps prevent performance bottlenecks. Instead of displaying all search results at once, you can use pagination:
const paginatedResults = paginate(results, { limit: 10, offset: 0 });
This ensures that only a subset of results is rendered at a time, improving UI responsiveness.
Searching through deeply nested objects increases lookup complexity. Instead of this:
const results = useSearch(
products,
query,
search({ fields: ["details.specs.name"] })
);
Consider flattening the data beforehand:
const flattenedProducts = products.map(({ details, ...rest }) => ({
...rest,
specsName: details.specs.name,
}));
const results = useSearch(
flattenedProducts,
query,
search({ fields: ["specsName"] })
);
This leads to faster lookups and avoids deep property resolution overhead.
Since arrays are reference types, passing a new features
array on every render causes unnecessary recalculations. Memoizing it prevents this:
const features = useMemo(() => [search({ fields: ["name", "category"] })], []);
const results = useSearch<Product>(products, query, ...features);
Writing reliable tests ensures that useSearch
behaves as expected across different scenarios.
Mocking useSearch
allows you to isolate it in unit tests, ensuring that your components don’t depend on external state changes:
jest.mock("useSearch", () => ({
useSearch: jest.fn(() => mockResults),
}));
test("filters products by name", () => {
const products = [
{ id: "1", name: "Laptop", price: 999 },
{ id: "2", name: "Phone", price: 499 },
];
const results = useSearch(products, "laptop", search({ fields: ["name"] }));
expect(results).toEqual([{ id: "1", name: "Laptop", price: 999 }]);
});
This verifies that the search function correctly filters the dataset based on the query.
If your app handles thousands of items, it’s good to test how efficiently useSearch
scales:
test("handles large datasets efficiently", () => {
const largeDataset = Array.from({ length: 10000 }, (_, i) => ({
id: `${i}`,
name: `Product ${i}`,
price: Math.random() * 1000,
}));
const startTime = performance.now();
const results = useSearch(
largeDataset,
"Product 999",
search({ fields: ["name"] })
);
const endTime = performance.now();
expect(results.length).toBeGreaterThan(0);
expect(endTime - startTime).toBeLessThan(50); // Ensure search runs within 50ms
});
Yes! useSearch
supports both arrays and single objects. If a single object is passed, it’s automatically wrapped in an array internally.
const singleProduct = { id: "1", name: "Laptop", price: 999 };
const results = useSearch(singleProduct, query, search({ fields: ["name"] }));
To reduce unnecessary recalculations on every keystroke, you can pass a debounce delay (in milliseconds) as the fourth argument:
const results = useSearch(products, query, 300, search({ fields: ["name"] }));
In this case, the search operation will be debounced by 300ms, reducing performance strain on frequent input changes.
Absolutely! useSearch
works well with additional filtering and sorting logic.
const results = useSearch(
products,
query,
search({ fields: ["name"] }),
filter({
conditions: [{ field: "price", operator: "lessThan", value: 500 }],
}) // Only show products less than $500
);
- ✅ Added grouping functionality.
- ✅ Improved TypeScript type inference.
- ⏳ Add more algorithms for fuzzy searchin
Found a bug or want to improve the hook?
Contribute on GitHub.
See the Changelog for updates and release notes.
MIT