Building a search component with autocomplete in React and Typescript

A search bar with autocomplete is becoming a standard feature of any web app with a lot of content. There are a ton of great React libraries out there which can implement a search feature with autocomplete but implementing one from scratch can be such a fun learning experience.

Recently, I built a similar feature in one of the apps I'm working on. In this blog post, I want to share what I learned and how you can implement an autocomplete search box. I am going to use React and Typescript to build an end-to-end search box without any 3rd party libraries.

Let us say you are building an amazing grocery store website and want the user to be able to search among available products. User can go through the list of products in each category and try to find the product they are looking i.e. like a dictionary. This approach is okay when you have a small number of products but the approach quickly fails when you have hundred of products. A better UX would be to give user the capability to search for the product they are looking for instead of going over hundreds of products. As the user types, they should be able to see the results grouped nicely in different categories such as dairy products, meat, fruits, vegetables etc.

Below you can watch a small demo of how I solved this problem and implemented an autocomplete search box.

Whenever I'm building a new feature in my app, I try to write down its data model first. I can do it here in terms of Typescript interfaces. Here, I have a base Typescript interface which defines the name and category of the product, which can then be extended to define various categories that can contain different properties.

// src/types.ts

interface Base<T> {
  category: T;
  name: string;
}

export interface Fruit extends Base<"fruit"> {
  imgUrl: string;
  type: string;
}

export interface Vegetable extends Base<"vegetable"> {
  imgUrl: string;
}

export interface Dairy extends Base<"dairy"> {
  imgUrl: string;
}

export interface Meat extends Base<"meat"> {
  type: string;
}

export interface PetCare extends Base<"petCare"> {
  type: string
}

Here is an example of mock data for the dairy category.

// src/data/dairy.ts

import { Dairy } from "../types";

export const mockDairy: Dairy[] = [
  {
    category: "dairy",
    name: "Milk",
    imgUrl: "https://example.com/milk",
  },
  {
    category: "dairy",
    name: "Cheese",
    imgUrl: "https://example.com/cheese",
  },
];

I created an array of all product categories to return the data as one big list of products and their details.

//src/data/index.ts

import {
  Fruit,
  Vegetable,
  Dairy,
  Meat,
  PetCare,
} from "../types";
import { mockFruits } from "./fruits";
import { mockVegetables } from "./vegetables";
import { mockDairy } from "./dairy";
import { mockMeats } from "./meats";
import { mockPetCare } from "./petCare";

export type Item = Fruit | Vegetable | Dairy | Meat | PetCare;

const mockData: Item[] = [
  ...mockFruits,
  ...mockVegetables,
  ...mockDairy,
  ...mockMeats,
  ...mockPetCare,
];

export default mockData;

API call can be simulated to fetch our mock data via a custom hook based on what the user types.

Exercise: Replace mock data with your own API to filter search results in real-time

// src/hooks/use-query.ts

import { useEffect, useState } from "react";
import data, { Item } from "../data";

const filter = (query: string): Item[] => {
  return data!.filter((item) => {
    return item.name.toLowerCase().indexOf(query.toLowerCase()) !== -1;
  });
};

export const getData = (query: string): Promise<Item[]> => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(filter(query));
    }, 200);
  });
};

interface Output {
  data: null | Item[];
  loading: boolean;
}

const useQuery = ({ query = "" }: { query: string }): Output => {
  const [data, setData] = useState<Item[] | null>(null);
  const [loading, setLoading] = useState<boolean>(false);

  useEffect(() => {
    setLoading(true);
    getData(query).then((response) => {
      setData(response);
      setLoading(false);
    });
  }, [query]);

  return {
    data,
    loading,
  };
};

export default useQuery;

For the sake of this post I have assumed that we can fetch the name and category of all the available items in one api call. For real life search box, results can be returned from the server as the user types

Before I can render the result of the user query, results need to be grouped by category. I can use an array.groupby function from a library like Lodash to solve this problem. Since I promised no 3rd party libraries, I implemented my own GroupBy function using Typescript generics which groups all the data objects in the list of products based on the user query.

// src/Search.tsx

export function GroupBy<T, K extends keyof T>(array: T[], key: K) {
   let map = new Map<T[K], T[]>();
   array.forEach(item => {
      let itemKey = item[key];
      if (!map.has(itemKey)) {
         map.set(itemKey, array.filter(i => i[key] === item[key]));
      }
   });
   return map;
}

Finally, I can write my search component. I'm using react-jss which is a css-in-jss library to style the component.

// src/Search.tsx

import { useState } from 'react';
import { createUseStyles } from 'react-jss'
import { Item } from "../data";
import useQuery from "../hooks/use-query"

export function GroupBy<T, K extends keyof T>(array: T[], key: K) {
  let map = new Map<T[K], T[]>();
  array.forEach(item => {
    let itemKey = item[key];
    if (!map.has(itemKey)) {
      map.set(itemKey, array.filter(i => i[key] === item[key]));
    }
  });
  return map;
}

type SearchItemProps = {
  item: Item
}

function SearchItem ({item}: SearchItemProps) {
  const classes = useStyles();
  return (
    <div className={classes.searchResultItem}>
      {
        ("imgUrl" in item) && <img height={'30px'} width={'30px'} src={item.imgUrl} alt={item.name} />
      }
      <span style={{ "marginLeft": "0.5rem" }}>{item.name}</span>
    </div>
  )
};

export default function EntitySearch() {
  const classes = useStyles();
  const [query, setQuery] = useState('');
  const { data, loading } = useQuery({ query: query })
  const groupedResults = data && data.length > 0 && GroupBy(data, 'category')

  return (
    <main className={classes.main}>
      <input
        type="search"
        onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
        value={query}
        placeholder="search...."
        className={classes.input}
      />
      {loading ? <p>Loading....</p> : query &&
        <div className={classes.searchResults}>
          {groupedResults && (
            Array.from(groupedResults.entries()).map(
              (item, ix) => {
                const [category, data] = item
                return (
                  <div key={ix} style={{ "borderTop": `${groupedResults.size > 1 ? "0" : "solid 0.1rem #d8dae5"}` }}>
                    <h1 className={classes.searchResultHeading}>{category.toUpperCase()}</h1>
                    {
                      data.map((item: Item, ix: number) => <SearchItem item={item} key={ix} />)
                    }
                  </div>
                )
              })
          )}
        </div>}
    </main>
  );
}


const useStyles = createUseStyles({
  main: {
    padding: "1rem 0 0 1rem",
    flexBasis: 0,
    flexGrow: 999,
    minInlineSize: "50%",
  },
  input: {
    width: "30rem",
    padding: "0.5rem"
  },
  searchResults: {
    width: "30rem",
  },
  searchResultHeading: {
    fontSize: "0.8rem",
    border: "solid 0.1rem #d8dae5",
    borderTop: 0,
    padding: "1rem 0 0.5rem 1rem",
    marginBottom: 0,
    marginTop: 0
  },
  searchResultItem: {
    display: "flex",
    alignItems: "center",
    padding: "0.5rem 0 0.5rem 1rem",
    border: "0.1rem solid #d8dae5",
    borderTop: 0,
  }
})

I hope you learned something new in this post. Checkout the complete source code on Github.

If you have any questions, feel free to reach out to me on Twitter.

Until next time and cheers!!