DEV Community

Cover image for Live Search in React js - Select with Mouse or Keyboard
Niraj Dhungana
Niraj Dhungana

Posted on

Live Search in React js - Select with Mouse or Keyboard

Displaying the live search results to the user while they are typing in the search bar is a great user experience. But creating the live search field with all the features. Like, select with a mouse, move selections on key up down and hide the search bar on blur.

Final Live Search Result

This is a little bit of a challenging task. So, let's try to go through all the challenges and create this awesome live search field. Where users can select the result with a mouse or by using the keyboard also the results will be hidden if we click outside.

The things we will cover

React & Tailwind Project Setup

To create this demo we are going to use obviously the React JS and Tailwind CSS for styling. If you want to you can use any other CSS framework or library. This logic will work for all of them.

If you don't know Tailwind CSS is a framework with a bunch of utility class names to style our HTML.

If you already have a project, you can directly add this logic to your existing project as well. But if you are starting from the ground like me run these commands to initialize a new project.

npx create-react-app live-search --template typescript
# or
npx create-react-app live-search // if you are using JavaScript
Enter fullscreen mode Exit fullscreen mode

Once you run one of the commands, the project will be initialized. Now you can install Tailwind CSS. First, change the directory with cd live-search and run these commands.

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

Then you have to go to your tailwind.config.js file and add these things inside your content.

// tailwind.config.js
module.exports = {
  content: ["./src/**/*.{js,jsx,ts,tsx}",],
...
Enter fullscreen mode Exit fullscreen mode

Then go to your index.css and replace all of the code with these three lines of code.

// index.css
// remove old CSS and add them
@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

Creating a Search Bar

A search bar is just an input element. But we will add a little bit of style to it so that it looks a little bit nice. For that, let's create a file called SearchBar.tsx (or jsx) inside the src folder and add these codes.

// inside SearchBar.tsx
import React, { FC } from "react";

interface Props<T> {
  value?: string;
  notFound?: boolean;
  onChange?: React.ChangeEventHandler;
  results?: T[];
  renderItem: (item: T) => JSX.Element;
  onSelect?: (item: T) => void;
}

const SearchBar = <T extends object>({ }: Props<T>): JSX.Element => {
  return (
    {/* Search bar */}
    <input
      type="text"
      className="w-full p-2 text-lg rounded border-2 border-gray-500 outline-none"
      placeholder="Search here..."
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

I don't think I have to explain these class names. They are explaining themselves. But these interface Props might look a little scary. This is just a Typescript thing which I will explain when we need them. So, let's render this component inside our App.tsx

// inside App.tsx
const App = () => {
  return (
    <div className="max-w-3xl mx-auto mt-10">
      <SearchBar />
    </div>
  );
};

export default App;
Enter fullscreen mode Exit fullscreen mode

Here I am using the fixed width for the div so that I can use this mx-auto to bring this div to the center of the webpage. If you try to run this code it will throw an error. Because renderItem is the required prop. Comment it out if you want to see the change.

Container to Render Search Results

Now we need a container to render our results. So for that, we will wrap our search input inside a div with class relative. And we will render a div below the input element with class absolute. With these classes, we will render the search container right below the input field.

// SearchBar.tsx

const SearchBar = <T extends object>({ }: Props<T>): JSX.Element => {
  return (
    <div className="relative">
      <input ...  />
     {/* Results Wrapper */}
      <div className="absolute mt-1 w-full p-2 bg-white shadow-lg rounded-bl rounded-br max-h-36 overflow-y-auto"></div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

With the help of other classes, like max-h-36 overflow-y-auto. It will create a container with a fixed height and show the scroll bar if the results are longer.

Creating a Fully Customizable Component

Now let's use the first props from inside the interface. As you see here we are using a little weird syntax for the interface and SearchBar components.

interface Props<T> { 
  results?: T[];
  renderItem: (item: T) => JSX.Element;
  onSelect?: (item: T) => void;
}

const SearchBar = <T extends object>({}:Props<T>) ...
Enter fullscreen mode Exit fullscreen mode

This is because we want to make this search bar so customizable. So that, it can take, render and give the same data type back to our renderItem and onSelect methods. Now you don't need to worry about types anymore. Typescript will grab the type from results and distribute it to renderItem and onSelect.

Now finally see how we can render our results.

// inside SearchBar.tsx
const SearchBar = <T extends object>({ results = [], renderItem }: Props<T>): JSX.Element => {
  return (
    <div className="relative">
      {/* Search bar */}
      <input ... />
      {/* Results Wrapper */}
      <div className="...">
        {/* Results Lists Goes here */}
        <div className="space-y-2">
          {results.map((item, index) => {
            return (
              <div
                key={index}
                className="cursor-pointer hover:bg-black hover:bg-opacity-10 p-2"
              >
                {renderItem(item)}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Very first thing, don't forget to give your results prop a default value empty array []. It will prevent unwanted errors. Then we are using resutls.map to render our results. Where we have renderItem method to render results. We have to pass this method from App.tsx which we will see later.

Then we have a wrapper div for renderItem with classes, hover:bg-black hover:bg-opacity-10. It is very important to give the user feedback on results hover. These classes will add background-color of black with opacity: 0.1 on the result hover.

Ok, now let's try to pass some data to our SearchBar. Because we used that <T> generic type we can pass any type of data but inside an array.

const profiles = [
  { id: "1", name: "Allie Grater" },
  { id: "2", name: "Aida Bugg" },
  { id: "3", name: "Gabrielle" },
  { id: "4", name: "Grace" },
  { id: "5", name: "Hannah" },
  { id: "6", name: "Heather" },
  { id: "7", name: "John Doe" },
  { id: "8", name: "Anne Teak" },
  { id: "9", name: "Audie Yose" },
  { id: "10", name: "Addie Minstra" },
  { id: "11", name: "Anne Ortha" },
];
const App = () => {
  return (
    <div className="max-w-3xl mx-auto mt-10">
      <SearchBar results={profiles} renderItem={(item) => <p>{item.name}</p>} />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

As I told you earlier you don't need to worry about the types for renderItem and onSelect. Here is the result if you hover over an item you will see its type. Cool right?

typescript in action

Now if you save your file and run the project with npm start. You will see something like this.

search results

Conditionally Rendering Results

Currently, these results are always visible so, let's solve this issue. For that, we can simply add a state called showResults and display the results according to the value of this state.

// inside SearchBar.tsx
const SearchBar = ... {
  const [showResults, setShowResults] = useState(true);

  // To toggle the search results as result changes
  useEffect(() => {
    if (results.length > 0 && !showResults) setShowResults(true);

    if (results.length <= 0) setShowResults(false);
  }, [results]);

   return ...
}
Enter fullscreen mode Exit fullscreen mode

The logic is pretty straightforward if there are results and the showResults state is false only then make it true. Otherwise, make it false. These logics will now hide and show our search results as the result changes its length.

But for that, we have to add the condition if showResults is true only then render the results.

const SearchBar = ... {
 ...
  return (
    <div className="relative">
      {/* Search bar */}
      <input ... />
      {/* Results Wrapper */}
      {showResults ? <div className="...">
        {/* Results Lists Goes here */}
        <div className="space-y-2">...</div>
      </div> : null}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Adding a Live Search Logic

Now let's write a little piece of code to add a fake search logic.

// SearchBar.tsx
const SearchBar = <T extends object>({onChange, ... }: Props<T>): JSX.Element => {
return (...
  <input
     onChange={onChange}
   />
...

// App.tsx

const profiles = [...]
const App = () => {
  const [results, setResults] = useState<{ id: string; name: string }[]>();

type changeHandler = React.ChangeEventHandler<HTMLInputElement>;
  const handleChange: changeHandler = (e) => {
    const { target } = e;
    if (!target.value.trim()) return setResults([]);

    const filteredValue = profiles.filter((profile) =>
      profile.name.toLowerCase().startsWith(target.value)
    );
    setResults(filteredValue);
  };

  return (
    <div className="max-w-3xl mx-auto mt-10">
      <SearchBar
        onChange={handleChange}
        results={results}
        renderItem={(item) => <p>{item.name}</p>}
      />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Even though this is too much code the logic is simple. First, we destructure the onChange inside SearchBar component and assigned it to the input element.

Then inside App.tsx we are handling the change event for SearchBar. So for the handleChange we are just using the array filter method to filter out the matching results.

If we found the results we will add them to the new state called results and if we have an empty input field we will reset those results to an empty array.

And at the end instead of the profiles, we will pass the results state to the results prop. If you do this you should see the search results only if you type out the correct profile names.

live searchresults

Inside the handleChange you can also fetch the data from the server. The only important thing is how will you add or reset your results state. Because if you forgot to reset the results state the results inside your live search container will be visible all the time.

Selecting Results on Key up-down

It's nice we have our search bar where we can search and render the results. But now we need to add a keyboard event to move our selection up and down.

const SearchBar = ... => {
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
    const { key } = e;

    // move down
    if (key === "ArrowDown"){ }

    // move up
    if (key === "ArrowUp"){ }

   // hide search results
    if (key === "Escape"){ }

   // select the current item
    if (key === "Enter"){ }
};

return (
<div
      tabIndex={1}
      onKeyDown={handleKeyDown}
      className="relative outline-none"
    >
   <input ... />
   ...
Enter fullscreen mode Exit fullscreen mode

We want to now listen to the keyDown event and we are doing that to the wrapping div inside SearchBar component. But we can not directly listen to the keyDown for divs. So we need to use the tabIndex prop. Also to avoid unwanted outlines on div focus (because we are using tabIndex there will be an outline) I am using the outline-none class.

Let's write down the keyboard logic one by one.

Handling the keyboard event is not that straightforward. Because while we are moving up or down we have to handle multiple things. Like displaying the correct UI, and scrolling in the right position if there are multiple results.

So for that first, we need a state called focusedIndex. As you can see the default value for this state is -1. Because later we will use this state to move our focus to the correct place. And we don't want to focus on anything at first. So -1 will work fine.

// inside SearchBar.tsx
const SearchBar = ... => {
  const [focusedIndex, setFocusedIndex] = useState(-1);

...
Enter fullscreen mode Exit fullscreen mode

Now with the logic below it will update our focused index with the correct timing. Also if you notice I am not using -1 or +1 to update our value. Instead, we are using the modulo (%) operater.

This is a small formula that we can use to update our count only within the range according to the length of the results. So if you have results with the length 6 and if you press the down key then the nextCount will start from 0 and goes to 5. If you press up this will be the opposite starts from 5 and goes to 0.

Which is the exact value that we need to be inside the bound of the result array. If we go beyond the length we will get index bound exception.

  const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
    let nextCount = 0;
    if (key === "ArrowDown") nextCount = (focusedIndex + 1) % results.length;

    if (key === "ArrowUp") nextCount = (focusedIndex + results.length - 1) % results.length;

    if (key === "Escape") setShowResults(false);

    if (key === "Enter") {
      e.preventDefault();
    }

    setFocusedIndex(nextCount);
  };
Enter fullscreen mode Exit fullscreen mode

Also, there is one more thing happening. If we press the Escape key we will hide the search results. But we are not handling the Enter we will do that later.

Styling the Focused Item

Now according to the focusedIndex we need to style the result. For that, we have to take a reference of specific results and change the style.

// SearchBar.tsx
const SearchBar = ... => {
 const resultContainer = useRef<HTMLDivElement>(null);
 return (
 ...
 {results.map((item, index) => {
  return ( 
   <div
    ref={index === focusedIndex ? resultContainer : null}
    style={{ backgroundColor: index === focusedIndex ? "rgba(0,0,0,0.1)" : "" }}
    key={index}
    className="cursor-pointer hover:bg-black hover:bg-opacity-10 p-2"
    >
      {renderItem(item)}
    </div>
    );
})}
Enter fullscreen mode Exit fullscreen mode

The main thing for this div is the ref and the style prop. What basically we are doing? If the index is equal to the focusedIndex we are assigning this div to the ref called resultContainer otherwise we are making it null. Why? You will understand it later.

Then we are changing the background color to black with an opacity of 10% if the index is equal to the focusedIndex.

Now with this logic, if you try to move up or down you will see the change but the results will stay in one place only. It will not scroll down or up as you update your index. To fix this use this logic.

// SearchBar.tsx
  useEffect(() => {
    if (!resultContainer.current) return;

    resultContainer.current.scrollIntoView({
      block: "center",
    });
  }, [focusedIndex]);
Enter fullscreen mode Exit fullscreen mode

If there is resultContainer we will use scrollIntoView method to scroll in to the exact point and we will make the focused item in the center with the option block: center.

Now you should have something like this.

live search animated gif

Selecting the Results

One of the important things for live search is selecting the result so let's see how we can do that.

// SearchBar.tsx
...
  const handleSelection = (selectedIndex: number) => {
    const selectedItem = results[selectedIndex];
    if (!selectedItem) resetSearchComplete();
    onSelect && onSelect(selectedItem);
    resetSearchComplete();
  };

  const resetSearchComplete = useCallback(() => {
    setFocusedIndex(-1);
    setShowResults(false);
  }, []);
...
Enter fullscreen mode Exit fullscreen mode

We need to now handle two things for selection first select the correct thing and send it back to the App.tsx. Because this is the component where we are using it right now. Then we also need to reset the search results.

For that, inside handleSelection we are accepting selectedIndex and extracting the selected item from inside the results array. If there is nothing just return otherwise call the onSelect with the selected result. Make sure you are destructuring it from the props.

const SearchBar = ({ onSelect, ...}) => 
Enter fullscreen mode Exit fullscreen mode

And then simply reset the states to their previous values. focusedIndex to -1 and showResults to false.

Now we need to add this handleSelection in two places. Inside Enter key press and on mouse down for the result item.

// SearchBar.tsx
// to select on enter
  const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
   ...
    if (key === "Enter") {
      e.preventDefault();
      handleSelection(focusedIndex);
    }

    setFocusedIndex(nextCount);
  };


return  (
...
{results.map((item, index) => {
  return ( 
   <div
    onMouseDown={() => handleSelection(index)}
    ref={index === focusedIndex ? resultContainer : null}
    style={{ backgroundColor: index === focusedIndex ? "rgba(0,0,0,0.1)" : "" }}
    ...
    >
      {renderItem(item)}
    </div>
    );

Enter fullscreen mode Exit fullscreen mode

With this now if you try to select with a mouse or keyboard you will select the correct item. If you want to see that add onSelect inside your SearchBar and log the result to the console.

// App.tsx
const App = () => {
  return (
    <div className="max-w-3xl mx-auto mt-10">
      <SearchBar
        ...
        onSelect={(item) => console.log(item)}
        />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Hiding Results onBlur

Now if you are thinking why onMouseDown why not onClick? Instead of giving you the answer, I will show you that.

Just add onBlur to your top wrapper div inside SearchBar component. And pass resetSearchComplete method to it.

// SearchBar.tsx
...
return (
    <div
      tabIndex={1}
      onKeyDown={handleKeyDown}
      onBlur={resetSearchComplete}
      className="relative outline-none"
    >
...
Enter fullscreen mode Exit fullscreen mode

Now if you click outside the search results will be gone which is the perfect result we want. Also, you will see the selected results inside your console (if you select any). But now if you change that onMouseDown to onClick results will be gone from the screen but you will not get the selected item inside the console.

Hmmm, why is that? This is because before onClick the onBlur will be fired. So, handleSelection will be never called.

Updating the value inside the search bar

Everything looks fine here. The only thing that we need now is to display the selected profile name inside our search bar. So, create a state called defaultValue inside SearchBar.tsx. Then pass this value to the input element.

// SearchBar.tsx
const SearchBar = ... ({ value }) => {
  const [defaultValue, setDefaultValue] = useState("");
...
return ...
   <input
     ...
     value={defaultValue}
   />
Enter fullscreen mode Exit fullscreen mode

If you remember we are also accepting a prop called value for our SearchBar component. So, if there is value prop available let's assign that to the defaultValue.

// SearchBar.tsx
  useEffect(() => {
    if (value) setDefaultValue(value);
  }, [value]);
Enter fullscreen mode Exit fullscreen mode

Now if the value changes this useEffect hook will update the defaultValue to the value.

Let's do one more thing inside App.tsx. Add a state called selectedProfile and update the value inside onSelect. Then pass selectedProfile.name to the value prop.

// App.tsx
const App = () => {
  const [selectedProfile, setSelectedProfile] = useState<{ id: string; name: string }>();   
   return (
       ...
      <SearchBar
         ...
        onSelect={(item) => setSelectedProfile(item)}
        value={selectedProfile?.name}
      />
Enter fullscreen mode Exit fullscreen mode

Now ladies and gentlemen you should see something like this.

live search updating input value

But there is one big problem. Once you have selected the result you can not make any changes to the input field.

To solve this issue we have to handle the onChange event directly inside our SearchBar component.

// SearchBar.tsx

const SearchBar = ({ onChange }) => {
  ...
  type changeHandler = React.ChangeEventHandler<HTMLInputElement>;
  const handleChange: changeHandler = (e) => {
    setDefaultValue(e.target.value);
    onChange && onChange(e);
  };

return (
...
      {/* Search bar */}
      <input
        onChange={handleChange}
...
Enter fullscreen mode Exit fullscreen mode

Here instead of passing onChange prop directly to the input element. Now we will pass handlChange and inside here we will update the defaultValue and then call the onChange prop. With this now you can update the value with onSelect and this SearchBar will automatically reset the value when the onChange occurs.

And I think this is more than enough for this project. You can see the final look and if you have anything to say at the bottom you will find my social media links.

Final Live Search Result

Top comments (0)