useFuzzySearchGroups
Fuzzy search across nested groups of items using Fuse.js.
useFuzzySearchGroups
provides fuzzy search across nested data structures where each group contains an array of items. It memoizes per-group Fuse instances for performance and returns only groups with matches.
Code
import { useMemo, useState } from 'react';
import Fuse, { FuseOptionKey } from 'fuse.js';
type UseFuzzySearchGroupsOptions<T, C> = {
searchKeys: FuseOptionKey<T>[];
threshold?: number;
ignoreLocation?: boolean;
groupKeyFn?: (group: C) => string; // Optional function to generate a key for a group
};
/**
* A hook that provides search functionality for nested items within groups.
*
* @param groups - List of group objects
* @param itemsKey - Key in each group that contains the array of items to search
* @param options - Search configuration options
* @returns Object containing query state, setter, and filtered groups
*/
export function useFuzzySearchGroups<Group, Item>(
groups: Group[],
itemsKey: keyof Group,
options: UseFuzzySearchGroupsOptions<Item, Group> = { searchKeys: [] },
): [
string,
(e: React.ChangeEvent<HTMLInputElement> | string) => void,
Group[],
boolean,
] {
const [query, setQuery] = useState<string>('');
const handleQueryChange = (
e: React.ChangeEvent<HTMLInputElement> | string,
) => {
const value = typeof e === 'string' ? e : e.target.value;
setQuery(value);
};
const {
searchKeys,
threshold = 0.2,
ignoreLocation = true,
groupKeyFn,
} = options;
// Create a memoized map of Fuse instances for each group
// This prevents recreating Fuse instances on every render
const fuseInstancesMap = useMemo(() => {
return groups.reduce(
(acc, group, index) => {
const items = group[itemsKey] as unknown as Item[];
if (items && Array.isArray(items)) {
// Use provided key function, or fallback to index as a simple key
// This avoids the expensive JSON.stringify operation
const groupKey = groupKeyFn ? groupKeyFn(group) : `group_${index}`;
acc[groupKey] = new Fuse(items, {
keys: searchKeys as FuseOptionKey<Item>[],
threshold,
ignoreLocation,
});
}
return acc;
},
{} as Record<string, Fuse<Item>>,
);
}, [groups, itemsKey, searchKeys, threshold, ignoreLocation, groupKeyFn]);
const filteredGroups = useMemo(() => {
if (!query) return groups;
return groups
.map((group, index) => {
// Get the items array from the category using the provided key
const items = group[itemsKey] as unknown as Item[];
if (!items || !Array.isArray(items)) {
return null;
}
// Use the pre-created Fuse instance for this group
const groupKey = groupKeyFn ? groupKeyFn(group) : `group_${index}`;
const fuse = fuseInstancesMap[groupKey];
if (!fuse) return null;
// Find matching items
const matchingItems = fuse.search(query).map((result) => result.item);
// Only keep categories with matching items
return matchingItems.length > 0
? { ...group, [itemsKey]: matchingItems }
: null;
})
.filter(Boolean) as Group[];
}, [groups, query, itemsKey, fuseInstancesMap, groupKeyFn]);
const isEmpty = useMemo(() => {
return filteredGroups.length === 0;
}, [filteredGroups]);
return [query, handleQueryChange, filteredGroups, isEmpty];
}
Example
Frontend
- Next.js
- React
Styling
- Tailwind
- Radix
Usage
'use client';
import React from 'react';
import { Search } from 'lucide-react';
import { Input } from '@/components/intuitive-ui/(native)/input';
import { useAutoFocusedInput } from '@/hooks/use-auto-focused-input';
import { useFuzzySearchGroups } from '@/hooks/use-fuzzy-search-groups';
import Container from '@/app/repository/[topic]/[slug]/_components/container';
interface IGroup {
id: string;
title: string;
items: { id: string; label: string }[];
}
const groups: IGroup[] = [
{
id: 'a',
title: 'Frontend',
items: [
{ id: '1', label: 'Next.js' },
{ id: '2', label: 'React' },
],
},
{
id: 'b',
title: 'Styling',
items: [
{ id: '3', label: 'Tailwind' },
{ id: '4', label: 'Radix' },
],
},
];
const BasicExample: React.FC = () => {
const [query, setQuery, filtered] = useFuzzySearchGroups<
IGroup,
IGroup['items'][number]
>(groups, 'items', { searchKeys: ['label'], groupKeyFn: (g) => g.id });
const inputRef = useAutoFocusedInput();
return (
<div className="flex flex-col gap-4">
<Container>
<div className="flex min-h-44 flex-col gap-2">
<Input
LeadingIcon={Search}
ref={inputRef}
value={query}
onChange={setQuery}
placeholder="Search across groups"
className="max-w-sm min-w-sm"
/>
<div className="space-y-3">
{filtered.map((group) => (
<div key={group.id}>
<div className="text-muted-foreground mb-1 text-xs font-medium">
{group.title}
</div>
<ul className="list-disc pl-6 text-sm">
{group.items.map((item) => (
<li key={item.id}>{item.label}</li>
))}
</ul>
</div>
))}
</div>
</div>
</Container>
</div>
);
};
export default BasicExample;