Implement Fuzzy Search with this Open Source Tool

Implement Fuzzy Search with this Open Source Tool

Table of Contents

As Developer Advocate, I’m not writing production code daily anymore, but I want to continue to code. My goal is to code at least twice a week. I’ve been able to remedy this issue by:

  • Contributing to open source
  • Completing freelance projects

My manager suggested that I also create my own project,

After brainstorming with my manager, I built CuteMoji, a static web application that allows you to search for GitHub Emojis. Since I've built a ton of static web applications before, I used technologies I had less experience with such as, Next.js, Tailwind CSS, SWR, and GitHub Actions.

My problem

After a few hours, I had a static web page with a search bar, and if you searched for the exact word, an emoji would appear. However, I realized this was a bad user experience. What if the user misspells a word?

I considered building my own algorithm to handle user errors. If I built an algorithm, I might spend more time maintaining and improving the algorithm than the actual web application, which was far from ideal since I plan to add more features to this project.

I also looked at other tools to handle the search, such as ElasticSearch and Algolia, but the setup needed was beyond my use case.

A day later, I came across the perfect, lightweight, open-source solution to implement fuzzy search: Fuse.js. It has zero dependencies, and it can work on the front end and back end.

Fuzzy searching happens when you type in a search engine like Google, and the search engine dynamically provides suggested results as you type. It's an algorithm that implements approximate string matching, which means the algorithm will return results similar to your search, but they don't need to be an exact match.

Setting up my components

I started with two empty components.

  • SearchBar.js - This would act as a search bar where users would type in certain emojis they were looking for.
import React from 'react';

const SearchBar = () => {
   return (
       <div className="p-8">
           <div className="bg-white flex items-center rounded-full shadow-xl">
               <input name="search"
                   type="search"
                   placeholder="Search for an emoji. Ex: heart"
                   autoComplete="off"
                   className="rounded-l-full w-full py-4 px-6 text-gray-700 leading-tight focus:outline-none"
                   id="search"
               />
               <div className="p-4">
               </div>
           </div>
       </div>
   );
}

export default SearchBar;
  • EmojiCards.js - This is where all returned emoji results would appear. I planned to limit the amount of emojis because the more results I showed, the less relevant it would be.
import React from 'react';

const EmojiCards = () => {
   return (
       <div>
       </div>
   );
}

export default EmojiCards;

I put both of these components in a parent component, which I unimaginatively named SearchWithEmojiCards.js.

import SearchBar from './SearchBar';
import EmojiCards from './EmojiCards';

const SearchWithEmojiCards = () => {

   return (
       <div className="w-3/6">
           <SearchBar />
           <EmojiCards />
       </div>
   );
}

export default SearchWithEmojiCards;

I chose to import and instantiate Fuse.js in the parent component and pass the necessary data I received from Fuse.js to EmojiCards.js and SearchBar.js as props.

Implementing Fuse.js

The steps:

1. I installed the npm package.

  • npm install fuse.js

2. I imported the Fusejs package into the SearchWithEmojiCards.js component.

import React, { useState } from 'react';
import SearchBar from './SearchBar';
import EmojiCards from './EmojiCards';
import Fuse from 'fuse.js';

3. My data set was initially an object, but Fuse.js expects an array of objects. I used the Object.entries method and map to produce the desired result. I also explicitly defined the names of each key as emoji_name and emoji_url.

const formattedData = Object.entries(data).map((entry) => ({ emoji_name: entry[0], emoji_url: entry[1] }));

Example of how my data originally looked:

 {
  "+1": "https://github.githubassets.com/images/icons/emoji/unicode/1f44d.png?v8",
  "-1": "https://github.githubassets.com/images/icons/emoji/unicode/1f44e.png?v8",
  "100": "https://github.githubassets.com/images/icons/emoji/unicode/1f4af.png?v8",
  "1234": "https://github.githubassets.com/images/icons/emoji/unicode/1f522.png?v8",
}

Example of the data after reformatting:

[
  {
    "emoji_name": "+1",
    "emoji_url": "https://github.githubassets.com/images/icons/emoji/unicode/1f44d.png?v8",
  }
  {
    "emoji_name": "-1",
    "emoji_url": "https://github.githubassets.com/images/icons/emoji/unicode/1f44e.png?v8",
  }
]

4. I passed the formatted API data into the SearchEmojiWithCards.js component. After that, I created a new fuse search instance at the top of my component.

  • In the example below, I pass my newly formatted emoji API data into the Fuse instance, and I pass in an options object.
  • The options object specifies the key I want users to search by emoji_name.
  • The returned results get scored by relevance. includeScore indicates that the score will be included and returned in the data.
import React, { useState } from 'react';
import SearchBar from './SearchBar';
import EmojiCards from './EmojiCards';
import Fuse from 'fuse.js';

const SearchWithEmojiCards = ({ apiData }) => {
   const options = {
       keys: [
           "emoji_name",
       ],
       includeScore: true
   };
   const fuse = new Fuse(apiData, options);

   return (
       <div className="w-3/6">
           <SearchBar />
           <EmojiCards />
       </div>
   );
}
export default SearchWithEmojiCards;

5. To perform a search, I added the fuse.search() method. The search method will search through my entire data collection and return a result based on the user's query. I also leveraged the useState hook to capture the results of the user's query.

import React, { useState } from 'react';
import SearchBar from './SearchBar';
import EmojiCards from './EmojiCards';
import Fuse from 'fuse.js';

const SearchWithEmojiCards = ({ apiData }) => {
   const [query, setQuery] = useState('')
   const options = {
       keys: [
           "emoji_name",
       ],
       includeScore: true
   };
   const fuse = new Fuse(apiData, options);
   const results = fuse.search(query);

   return (
       <div className="w-3/6">
           <SearchBar query={query} setQuery={setQuery}/>
           <EmojiCards />
       </div>
   );
}

export default SearchWithEmojiCards;

6. In the SearchBar component, I set the state of the user's input with the setQuery() function and set the state's input variable, query, to the value of the input.

import React from 'react';

const SearchBar = ({ setQuery, query }) => {
   function handleOnSearch({ currentTarget = {} }) {
       const { value } = currentTarget;
       setQuery(value);
   }
   return (
       <div className="p-8">
           <div className="bg-white flex items-center rounded-full shadow-xl">
               <input name="search"
                   type="search"
                   placeholder="Search for an emoji. Ex: heart"
                   autoComplete="off"
                   className="rounded-l-full w-full py-4 px-6 text-gray-700 leading-tight focus:outline-none"
                   id="search"
                   value={query}
                   onChange={handleOnSearch} />
               <div className="p-4">
               </div>
           </div>
       </div>
   );
}

export default SearchBar;

To see what the data output looked like, I printed the data as a console.log() each time a user searched for an emoji.

Here's an example of the output:

[
  {
    "item": {
      "emoji_name": "+1",
      "emoji_url": "https://github.githubassets.com/images/icons/emoji/unicode/1f44d.png?v8",
    },
    "refIndex": 0,
    "score": 0.03,
  },
  {
    "item": {
      "emoji_name": "-1",
      "emoji_url": "https://github.githubassets.com/images/icons/emoji/unicode/1f44e.png?v8",
    },
    "refIndex": 0,
    "score": 0.04
  }
]

7. I mapped through the array to retrieve the item objects. However, if the user didn't search, emoji results wouldn't show, and my page looked bare! I decided to write a conditional statement: if the user inputs a value into the search bar, then Fuse.js could deliver those specific search results, else show a few default emojis. I passed the data into my EmojiCards.js component so that I could further modify the data.

import React, { useState } from 'react';
import SearchBar from './SearchBar';
import Fuse from 'fuse.js';
import EmojiCards from './EmojiCards';

const SearchWithEmojiCards = ({ apiData }) => {
   const [query, setQuery] = useState('')

   const options = {
       keys: [
           "emoji_name",
       ],
       includeScore: true
   };
   const fuse = new Fuse(apiData, options);
   const results = fuse.search(query)
   const emojiResult = query ? results.map(result => result.item) : apiData;

   return (
       <div className="w-3/6">
           <SearchBar query={query} setQuery={setQuery} />
           <EmojiCards emojiResult={emojiResult} />
       </div>
   );
}

export default SearchWithEmojiCards;

8. In the EmojiCards.js component, I mapped through the data and limited it to only the first six most relevant results. I styled the cards as desired.

import React from 'react';

const EmojiCards = ({ emojiResult }) => {
   return (
       <div>
           <ol className="flex row justify-center">
               {emojiResult.slice(0, 6).map((emoji, value) => (
                   <li key={value} className="p-4 rounded-md w-max my-8 mx-3 shadow-2xl text-center" >
                       <img className="m-auto w-16 h-16" src={emoji.emoji_url} alt={emoji.emoji_name.replaceAll('_', ' ')} />
                       <span>{emoji.emoji_name.replaceAll('_', ' ')}</span>
                   </li>
               ))}
           </ol>
       </div>
   );
}

export default EmojiCards;

Here’s a screenshot of the final result:

Screen Shot 2021-10-07 at 11.01.14 PM.png

The site is hosted live on an AWS S3 Bucket via Pulumi. Feel free to try it out!

Incorporate Fuse.js into your project by following the official documentation .

Hoping to contribute to Fuse.js? Check out the repo .

You can also check out my repo below. Feel free to suggest improvements or features I can add to this web app. I’ll be adding more to this project and documenting my learnings as I go, so stay tuned!

Did you find this article valuable?

Support Rizel Scarlett by becoming a sponsor. Any amount is appreciated!