Implementing Fuse.js for Website Search Functionality

Madeline Streilein background

Madeline Streilein —

React logo and fuse logo with a magnifying glass

Topics

We recently were tasked with setting up search functionality on Should I Ask For Gender, a project within our Labs space that helps delineate when asking for a user's gender is appropriate (hint: most of the time, it is not). 

The user experience we sought to create involved search-as-you-type on the glossary and resources routes, displaying all matching instances of the keyword in the various fields of the glossary or resource elements, otherwise rendering the full glossary or list of resources if no results are found.

We sought a solution that was:

  • Fast
  • Client-side
  • Able to integrate with our existing React-Airtable app

As our team has implemented Solr on past projects, we considered Lunar.js for a wee moment (kudos for their clever slogan: Lunr: A bit like Solr, but much smaller and not as bright), but ultimately landed on Fuse.js, a fuzzy-search library. In addition to meeting the needs described above, Fuse.js is lightweight and has zero dependencies.

What is fuzzy searching, you may ask?

RELATED READ

Cultivating a More Gender-Inclusive Web

Generally speaking, fuzzy searching (more formally known as approximate string matching) is the technique of finding strings that are approximately equal to a given pattern (rather than exactly).

Our implementation

Below is a diagram of files in this project relevant to the search functionality:

app
│--App.js
│--constants.js
│
└───context
│   │--archiveContext.js
│   
└───components
    │ --searchBar.js
    │ --cardList.js

We fetch the resources and glossary terms from an Airtable API. App.js is wrapped in an ArchiveProvider defined in app/context/archiveContext.js so that this data can be globally available (and to avoid superfluous prop drilling). ArchiveContext accepts the following as value props to pass on to the searchBar component and also to render search matches in /components/cardList.js:

  • searchTerm
  • setSearchTerm
  • searchResults
  • setSearchResults
  • glossary
  • resources

The search magic on shouldiaskforgender.com happens in /components/searchBar.js. Based on the current location, we add resources or glossary terms to state along with keys to define which nested fields should be searched. Asynchronous handleSearch updates as a user types in the input and creates a Fuse constructor with our searchable terms and search options.

import React, { useState, useEffect, useContext, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import Fuse from 'fuse.js';
import { ArchiveContext } from '../context/archiveContext';
import { searchOptions } from '../constants';

const searchBar = () => {
  const { glossary, resources, setSearchResults, searchTerm, setSearchTerm } = useContext(
    ArchiveContext
  );
  const location = useLocation();
  const path = location.pathname.split('/')[1];

  const [searchable, setSearchable] = useState([]);
  const [options, setOptions] = useState({});
  const inputRef = useRef(null);

  // Add resources to state, along with fuse options.
  useEffect(() => {
    if (resources && path === 'resources') {
      searchOptions.keys = ['source_author', 'summary', 'title'];
      setOptions(searchOptions);
      setSearchable(resources);
    }
  }, [resources, path]);

  // Add glossary to state, along with fuse options.
  useEffect(() => {
    if (glossary && path === 'glossary') {
      searchOptions.keys = ['definition', 'term'];
      setOptions(searchOptions);
      setSearchable(glossary);
    }
  }, [glossary, path]);

  const handleSearch = async event => {
    setSearchTerm(event.target.value);

    if (searchable.length > 0) {
      const fuse = new Fuse(searchable, options);
      const foundResults = await fuse.search(searchTerm);
      setSearchResults(foundResults);
    }
  };

  const showButton = () => {
    const isFocused = inputRef.current?.matches(':focus');
    const isHovered = inputRef.current?.matches(':hover');
    return searchTerm.length > 0 && (isFocused || isHovered);
  };

  const clearSearchTerm = () => {
    setSearchTerm('');
  };

  return (
    <div>
      <div>
        <input
          type="search"
          placeholder="Search..."
          value={searchTerm}
          onChange={handleSearch}
          ref={inputRef}
          aria-label="Enter search term."
        />
        {showButton() && (
          <button onClick={clearSearchTerm}>
            <span className="sr-only">Clear Search</span>
          </button>
        )}
      </div>
      {searchResults.length === 0 && searchTerm.length > 1 && (
        <p>
          <em>No results found</em>
          <br />
          <a href="mailto:info@savaslabs.com">Email us</a> to add a term.
        </p>
      )}
    </div>
  );
};

export default searchBar;

As soon as search results are set, they are available in /components/cardList.js via archiveContext. There, we render the matches if they are present, otherwise, we render the full list of resources or glossary terms with a no results found message.

To highlight individual search terms in the results, we'd recommend React Highlighter. We used the searchTerm prop passed via ArchiveContext as the search prop required by react-highlighter on each Card that CardList renders. To see our implementation of this and more, check out the Should I Ask For Gender repo for the full app build.

Our chosen search options

export const searchOptions = {
  includeScore: true,
  includeMatches: true,
  distance: 100,
  threshold: 0.1,
  ignoreLocation: true,
};

ignoreLocation

I did a quick and dirty implementation at first as a proof of concept, whereby I didn't read all of the options described in the documentation, or their scoring theory. When a search for "woman" on /resources wasn't matching the description of an article I could see contained that term prior to search, I knew something was up. The solution was to add ignoreLocation: true to the search options provided in constants.js. Without this option, Fuse looks for matches within the first 10 characters of the string. Since we want glossary terms and articles that contain the term anywhere within the item to match, we need to ignore the location.

The docs are well defined so I invite you to read more about the other search options we've chosen!