Using Fuse.js with React to build an advanced search (with highlighting)
Tags: fusejs, react, highlight, search
Published: 2021-01-19 18:27:46

[Fuse.js](https://fusejs.io/) 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](balazssagi/use-fuse) for that! It wraps the Fuse instance into a custom hook, so the usage becomes: 

```jsx
// 😶 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 and `onSearch` 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](https://github.com/krisk/Fuse/issues/229)).

So let's rewrite the hook to make all of that happen:

```jsx
// 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:

```jsx
// 🎉 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:

```js
[
  {
    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`:

```jsx
// 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 into `hit.item` and its matches will be in `hit.matches[0]` without a `key` 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 our `resolveAttribute` helper.
- We could have built a string with HTML markup and pass it inside a `<span>` with the `dangerouslySetinnerHTML` 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 that `indices` 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:

```jsx
// 😎 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.