DEV Community

loading...

Using Fuse.js with React to build an advanced search (with highlighting)

noclat profile image Nicolas Torres ・5 min read

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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).

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,
  };
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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,
  },
  // ...
]
Enter fullscreen mode Exit fullscreen mode

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 = []) => {
  const pair = indices.pop();
  return !pair ? value : (
    <>
      {highlight(value.substring(0, pair[0]), indices)}
      <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;
Enter fullscreen mode Exit fullscreen mode

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:

// 😎 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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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.

Discussion (1)

pic
Editor guide
Collapse
noclat profile image
Nicolas Torres Author

Actually just found out we could use Fuse.config.getFn instead of resolveAttribute. It does exactly the same thing.