Fuse.js is really a great library to build a fuzzy (typo-tolerant) search engine in seconds, as it has great performances as long as the dataset size remains decent, and a very small and clear API.
But I've run into many difficulties trying to make it work with React. I had started by creating the Fuse instance inside a FuseSearch input component, and then passing the results with a callback property. But once we type, the parent receive the results, refresh its DOM, and therefore refresh my FuseSearch component updating the instance and triggering a new search... causing an infinite loop caught by React max update limit ("Maximum update depth exceeded."). (Won't share the code here, way too verbose for a misdirection.)
Creating a useFuse
custom hook
To avoid the update loop, the solution is to instantiate Fuse in the component that will display the results directly. Thanks to balazssagi/use-fuse for that! It wraps the Fuse instance into a custom hook, so the usage becomes:
// 😶 decent solution, but not yet there
import React, { useCallback, useState } from 'react';
import { useFuse } from 'features/useFuse';
const MySearch = (props) => {
const [query, setQuery] = useState('');
const hits = useFuse(props.list, query);
const onSearch = useCallback(
(e) => setQuery(e.target.value.trim()),
[]
);
return (
<input
name="search"
type="search"
placeholder="Search..."
autoComplete="off"
onKeyUp={onSearch}
onChange={onSearch} // handles "clear search" click
/>
<p>Results for "{query}":</p>
<ol>
{hits.map(hit => (
<li key={hit.refIndex}>
{hit.item.name}
</li>
))}
</ol>
);
};
However a few things bother me with this hook, as Fuse has many more options. I want to be able to:
- tweak options for each instance,
- avoid declaring
query
state andonSearch
handler every time, - debounce search on keypress,
- return the full list of results when query is empty, because Fuse won't (it's understandably out of its scope).
So let's rewrite the hook to make all of that happen:
// features/useFuse.js
import Fuse from 'fuse.js';
import { useCallback, useMemo, useState } from 'react';
import { debounce } from 'throttle-debounce';
export const useFuse = (list, options) => {
// defining our query state in there directly
const [query, updateQuery] = useState('');
// removing custom options from Fuse options object
// NOTE: `limit` is actually a `fuse.search` option, but we merge all options for convenience
const { limit, matchAllOnEmptyQuery, ...fuseOptions } = options;
// let's memoize the fuse instance for performances
const fuse = useMemo(
() => new Fuse(list, fuseOptions),
[list, fuseOptions]
);
// memoize results whenever the query or options change
const hits = useMemo(
// if query is empty and `matchAllOnEmptyQuery` is `true` then return all list
// NOTE: we remap the results to match the return structure of `fuse.search()`
() => !query && matchAllOnEmptyQuery
? fuse.getIndex().docs.slice(0, limit).map((item, refIndex) => ({ item, refIndex }))
: fuse.search(query, { limit }),
[fuse, limit, matchAllOnEmptyQuery, query]
);
// debounce updateQuery and rename it `setQuery` so it's transparent
const setQuery = useCallback(
debounce(100, updateQuery),
[]
);
// pass a handling helper to speed up implementation
const onSearch = useCallback(
(e) => setQuery(e.target.value.trim()),
[setQuery]
);
// still returning `setQuery` for custom handler implementations
return {
hits,
onSearch,
query,
setQuery,
};
};
Okay so now our example becomes way less verbose and we can set different options per instance:
// 🎉 Here we are!
import React from 'react';
import { useFuse } from 'features/useFuse';
const MySearch = (props) => {
const { hits, query, onSearch } = useFuse(props.list, {
keys: ['name'],
matchAllOnEmptyQuery: true,
});
return (
<input
name="search"
type="search"
placeholder="Search..."
autoComplete="off"
onKeyUp={onSearch}
onChange={onSearch} // handles "clear search" click
/>
<p>Results for "{query}":</p>
<ol>
{hits.map(hit => (
<li key={hit.refIndex}>
{hit.item.name}
</li>
))}
</ol>
);
};
We still have access to setQuery
if we have to use a custom component hijacking the key events, from a UI kit for example.
Highlighting results
When turning on the option includeMatches
, Fuse returns a matches
object alongside each item:
[
{
item: {/* ... */},
matches: {
indices: [[1,1], [3,5]],
key: 'path.to.key',
value: "The value of item[path][to][key]",
},
refIndex: 0,
},
// ...
]
But again, I couldn't find any satisfying highlighting component for Fuse, so I've built my own, FuseHighlight
:
// components/FuseHighlight.jsx
import React from 'react';
// Finds `obj[path][to][key]` from `path.to.key`
const resolveAttribute = (obj, key) => key
.split('.')
.reduce((prev, curr) => prev?.[curr], obj);
// Recursively builds JSX output adding `<mark>` tags around matches
const highlight = (value, indices = [], i = 1) => {
const pair = indices[indices.length - i];
return !pair ? value : (
<>
{highlight(value.substring(0, pair[0]), indices, i+1)}
<mark>{value.substring(pair[0], pair[1]+1)}</mark>
{value.substring(pair[1]+1)}
</>
);
};
// FuseHighlight component
const FuseHighlight = ({ hit, attribute }) => {
const matches = typeof hit.item === 'string'
? hit.matches?.[0]
: hit.matches?.find(m => m.key === attribute);
const fallback = typeof hit.item === 'string'
? hit.item
: resolveAttribute(hit.item, attribute);
return highlight(matches?.value || fallback, matches?.indices);
};
export default FuseHighlight;
By extracting the helper functions from the components, I ensure they are not recomputed each time the component renders, so basically at (almost) each key press.
A few things to take into consideration:
- Fuse handles both string array and object array searches. So we have to ensure that our highlighting still works with string array searches. In that case, there's no need to pass the
attribute
argument, as the string value will be stored directly intohit.item
and its matches will be inhit.matches[0]
without akey
attribute. - When there is no
matches
indices (empty query), we still want to return the whole string value. In that case we need to find that value in the original item data, and we do so using ourresolveAttribute
helper. - We could have built a string with HTML markup and pass it inside a
<span>
with thedangerouslySetinnerHTML
attribute. I did at first, but it adds an unnecessary DOM element. We're in a JSX file, so let's take the most out of it. - The
highlight
helper relies heavily on the fact thatindices
are sorted in ascending order and have no overlaps. That way, we just extract the last match (indices.pop()
), directly wraps the markup around it, and recursively apply the same instructions to the remaining beginning of the string value.
We can now complete our example, and it's as simple as that:
// 😎 Bring it on!
import React from 'react';
import FuseHighlight from 'components/FuseHighlight';
import { useFuse } from 'features/useFuse';
const MySearch = (props) => {
const { hits, query, onSearch } = useFuse(props.list, {
keys: ['name'],
includeMatches: true,
matchAllOnEmptyQuery: true,
});
return (
<input
name="search"
type="search"
placeholder="Search..."
autoComplete="off"
onKeyUp={onSearch}
onChange={onSearch} // handles "clear search" click
/>
<p>Results for "{query}":</p>
<ol>
{hits.map(hit => (
<li key={hit.refIndex}>
<FuseHighlight
hit={hit}
attribute="name"
/>
</li>
))}
</ol>
);
};
We have created two elegant hook and component that completely enhance our developer experience while preserving the performances. They have a very limited footprint, and yet we have everything we need to build a nice advanced search into our applications.
Top comments (6)
yeah fuse.js is amazing :) however found it a bit difficult to get more correct highlighting of results going...typed mark and it highlighted frankenstein in one example on github
Awesome 🤩
Actually just found out we could use
Fuse.config.getFn
instead ofresolveAttribute
. It does exactly the same thing.nice post. maybe if you can share pics of the app as it seems to appear all the names if you don't start a query in the input using Fuse? thks; All the best for Epycure 🇫🇷🇫🇷🇫🇷
This was really helpful! Merci