Create a Search Bar in React

No Additional Libraries

Search bars are a very common element of websites. They help users to find resources quickly through automatic suggestions. By adding a search bar to your application, you can drastically improve the user experience by making your resources more accessible.

In this tutorial, we are creating a simple and very customizable search bar component in React, without using any additional libraries. Here's a quick overview of the features our search bar will include:

  • If the user types text into the search bar, the component will fetch and display the new suggestions

  • We will use debounce to limit the hits on the backend

  • If the search bar loses focus, the suggestions will disappear

  • If the user clicks on a suggestion, the whole object of the selected result will be loaded into its state

Setup

First of all, we create a new React application using Vite. Click here to see, why Vite is the better alternative to Create React App when it comes to creating new React applications.

#using npm
npm create vite
#using yarn
yarn create vite

Follow the instructions given by Vite. We will use React with plain JavaScript as the variant. After the creation, install the dependencies and run the app.

yarn && yarn dev

After that, we remove the App.css file and its import in the App.jsx. We also remove all boilerplate code within the App.jsx. The App.jsx should now look like this:

function App() {
  return <div></div>;
}

export default App;

The index.css file should also be updated to look like this:

body {
  margin: 0;
  padding: 0;
}

Textbox and Fetching

We can now proceed by creating a SearchBar.jsx component.

import { useEffect, useState } from 'react';
import styles from './SearchBar.module.css';

const SearchBar = () => {
  const [value, setValue] = useState(''); //this is the value of the search bar
  const [suggestions, setSuggestions] = useState([]); // this is where the search suggestions get stored

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(
          `https://dummyjson.com/products/search?q=${value}`
        );
        if (!response.ok) {
          console.log(response);
          return;
        }
        const { products } = await response.json();
        setSuggestions(products);
      } catch (error) {
        console.log(error);
      }
    };
    fetchData();
  }, [value]);

  return (
    <div className={styles.container}>
      <input
        type="text"
        className={styles.textbox}
        placeholder="Search data..."
        value={value}
        onChange={(e) => {
          setValue(e.target.value);
        }}
      />
    </div>
  );
};

export default SearchBar;

In the component, we start by creating an input and binding it to a state. Then, we use the useEffect-hook to fetch the data. We are using the fake REST API https://dummyjson.com to retrieve test data that is searchable. If the request is successful, the data is stored in the suggestions-state. Otherwise, the error message is printed in the console. This is by no means a production-ready fetching and error-handling implementation. To learn how to do professional and user-friendly error handling, click here.

Now, we can do a bit of styling for the textbox by creating SearchBar.module.css :

.textbox {
  /* Styling of the textbox */
  border: 1px solid whitesmoke;
  height: 2rem;
  outline: none;
  box-sizing: border-box;
  padding: 1px 10px;
  transition: 200ms ease-in-out;
}
.textbox:focus {
  /* Changing border color everytime the textbox has focus */
  border: 1px solid lightblue;
  transition: 200ms ease-in-out;
}

Displaying the Results

Now, it's time to show the search suggestions. For that, we first need a state to control the visibility of the results:

const [hideSuggestions, setHideSuggestions] = useState(true);

Furthermore, we need to add some JSX right under the textbox:

<div
    className={`${styles.suggestions} ${hideSuggestions && styles.hidden}`}
>
    {suggestions.map((suggestion) => (
        <div className={styles.suggestion}>{suggestion.title}</div>
    ))}
</div>

This renders a suggestions div as well as a div for every single suggestion. Within the child divs, the respective title is rendered. One important thing here is, that the className hidden is only added if hideSuggestions===true .

Additionally, we need to provide two extra props to the input:

onFocus={() => setHideSuggestions(false)}
onBlur={async () => {
    setTimeout(() => {
        setHideSuggestions(true);
    }, 200);
}}

The first one is pretty trivial: Once the textbox is in focus, we show the suggestions. If we leave the focus though, we need to wait for some time until we can hide the suggestions again. This is because if we click on a specific suggestion, the onBlur-Event of the input is triggered before the onClick-Event of the suggestion. Without waiting, we cannot detect, whether the user has just clicked somewhere to lose focus or if he/her clicked on a suggestion. The delay of 200ms will prevent this from happening.

On top of that, we can now add some more styles, resulting in the following SearchBar.module.css file:

.container {
  width: 30rem;
  position: relative;
}

.textbox {
  /* Styling of the textbox */
  border: 1px solid whitesmoke;
  height: 2rem;
  outline: none;
  box-sizing: border-box;
  padding: 1px 10px;
  transition: 200ms ease-in-out;
  width: 100%;
}
.textbox:focus {
  /* Changing border color everytime the textbox has focus */
  border: 1px solid lightblue;
  transition: 200ms ease-in-out;
}

.suggestions {
  /* Styling of the suggestion container */
  border: 1px solid whitesmoke;
  width: 100%;
  height: fit-content;
  max-height: 20rem;
  overflow-y: scroll;
  position: absolute;
  background-color: white;
  z-index: 10;
}

.suggestions.hidden {
  /* Hides the suggestions if this class is used */
  visibility: hidden;
}

.suggestion {
  /* Styling of a single suggestion */
  cursor: pointer;
  padding: 1px 10px;
  height: 2rem;
  display: flex;
  align-items: center;
}
.suggestion:hover {
  /* Changing background color on hover */
  background-color: whitesmoke;
}

Loading the State of the Result

Now it's time to show the result once the user clicks on a suggestion. For that, we need another state as well as a function to find the suggestion with a given title:

const [result, setResult] = useState(null);

const findResult = (title) => {
    setResult(suggestions.find((suggestion) => suggestion.title === title));
};

Within the suggestion element, we now call this method if an onClick-Event gets triggered:

<div
    className={styles.suggestion}
    onClick={() => findResult(suggestion.title)}
>
    {suggestion.title}
</div>

For actually displaying the result, we then create an additional component Results.jsx :

import React from 'react';

const Result = ({ title, description, price, rating, category }) => {
  return (
    <div>
      <p>
        <b>Title:</b> {title}
      </p>
      <p>
        <b>Description:</b> {description}
      </p>
      <p>
        <b>Price:</b> {price}
      </p>
      <p>
        <b>Rating:</b> {rating}
      </p>
      <p>
        <b>Category:</b> {category}
      </p>
    </div>
  );
};

export default Result;

We can now use this component by integrating it within the SearchBar component. For that, we wrap the whole component into a React Fragment. Within this fragment, we now place the search bar container and the result. Our SearchBar.jsx should now look like this:

import { useEffect, useState } from 'react';
import Result from './Result';
import styles from './SearchBar.module.css';

const SearchBar = () => {
  const [value, setValue] = useState(''); //this is the value of the search bar
  const [suggestions, setSuggestions] = useState([]); // this is where the search suggestions get stored
  const [hideSuggestions, setHideSuggestions] = useState(true);
  const [result, setResult] = useState(null);

  const findResult = (title) => {
    setResult(suggestions.find((suggestion) => suggestion.title === title));
  };

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch(
          `https://dummyjson.com/products/search?q=${value}`
        );
        if (!response.ok) {
          console.log(response);
          return;
        }
        const { products } = await response.json();
        setSuggestions(products);
      } catch (error) {
        console.log(error);
      }
    };
    fetchData();
  }, [value]);

  return (
    <>
      <div className={styles.container}>
        <input
          onFocus={() => setHideSuggestions(false)}
          onBlur={async () => {
            setTimeout(() => {
              setHideSuggestions(true);
            }, 200);
          }}
          type="text"
          className={styles.textbox}
          placeholder="Search data..."
          value={value}
          onChange={(e) => {
            setValue(e.target.value);
          }}
        />
        <div
          className={`${styles.suggestions} ${
            hideSuggestions && styles.hidden
          }`}
        >
          {suggestions.map((suggestion) => (
            <div
              className={styles.suggestion}
              onClick={() => findResult(suggestion.title)}
            >
              {suggestion.title}
            </div>
          ))}
        </div>
      </div>
      {result && <Result {...result} />}
    </>
  );
};

export default SearchBar;

Performance Optimizations

The functionality of the search bar is now complete. However, there could be some issues, especially if you have many resources:

  1. The fetching will get triggered every time we add or remove a letter in the textbox. If I want to search for the value "phone" for example, the endpoint gets called 5 times, when we theoretically only require it to fetch the suggestions once.

  2. If you have thousands of products, loading all of them into our suggestions state at once takes up a big amount of space. Chances are, that the user doesn't scroll through thousands of suggestions anyways.

To solve the first problem, we will use a so-called debounce function. This is a function that enables our application to only trigger a certain event (in our case the fetching of our suggestions) after a certain delay. For example, our debounce function would make sure, that the fetching of the suggestions only gets triggered, once the user has stopped typing for at least one second.

To make this work, we need to use some custom hooks. These were created by WebDevSimplified and make the integration of debounce very easy.
First, create a hooks/-directory under the src/ -directory. Then, create the following two files: useTimeout.js , useDebounce.js and paste this code into the respective file:

import { useCallback, useEffect, useRef } from 'react';

export default function useTimeout(callback, delay) {
  const callbackRef = useRef(callback);
  const timeoutRef = useRef();

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  const set = useCallback(() => {
    timeoutRef.current = setTimeout(() => callbackRef.current(), delay);
  }, [delay]);

  const clear = useCallback(() => {
    timeoutRef.current && clearTimeout(timeoutRef.current);
  }, []);

  useEffect(() => {
    set();
    return clear;
  }, [delay, set, clear]);

  const reset = useCallback(() => {
    clear();
    set();
  }, [clear, set]);

  return { reset, clear };
}
import { useEffect } from 'react';
import useTimeout from './useTimeout';

export default function useDebounce(callback, delay, dependencies) {
  const { reset, clear } = useTimeout(callback, delay);
  useEffect(reset, [...dependencies, reset]);
  useEffect(clear, []);
}

For a detailed explanation of how these hooks work, please refer to this video by WebDevSimplified.

To use the useDebounce-hook, just replace the useEffect-hook with the useDebounce-hook. Add a delay of 1000ms as an argument right before the dependency array. This block should now look like this:

useDebounce(
    () => {
      const fetchData = async () => {
        try {
          const response = await fetch(
            `https://dummyjson.com/products/search?q=${value}`
          );
          if (!response.ok) {
            console.log(response);
            return;
          }
          const { products } = await response.json();
          setSuggestions(products);
        } catch (error) {
          console.log(error);
        }
      };
      fetchData();
    },
    1000,
    [value]
  );

For the second issue listed above, we limit the maximum number of suggestions that are fetched to 10. Thankfully, the dummyjson API has a query parameter called limit, which does exactly that. If you implement your own backend, this feature should also be integrated. For our example, we just need to include the limit parameter in the request:

const response = await fetch(
  `https://dummyjson.com/products/search?q=${value}&limit=10`
);

Making the Search Bar Reusable

Now it's time to spend a few minutes refactoring to make the SearchBar component reusable for all kinds of data. For that, we will separate all variables and functions, that are not part of the search bar itself. This includes:

  1. The function to fetch the data

  2. The Result component and state

  3. The property which is displayed in the suggestions (in our case the title)

We will define all of these in the parent of the SearchBar component, the App.jsx. Let's start with the fetchData-function:

const fetchData = async (value) => {
  const response = await fetch(
    `https://dummyjson.com/products/search?q=${value}&limit=10`
  );
  if (!response.ok) {
    console.log(response);
    return;
  }
  const { products } = await response.json();
  return products;
};

As you can see, we removed the error-handling from the function as this will be done in the SearchBar component. Also, we return the result of the fetch instead of setting our suggestions state. Within the SearchBar.jsx, we pass this function as a prop and use it as follows:

useDebounce(
  async () => {
    try {
      const suggestions = await fetchData(value);
      setSuggestions(products || []);
    } catch (error) {
      console.log(error);
    }
  },
  1000,
  [value]
);

As you can see, we set the suggestions to either the return of our fetchData-function or an empty array.

Now, let's move our result as well as our displayed suggestion property to the App.jsx. For that, we include the result-state and the rendering of the results. We then pass the setResult-function and the suggestion key as props into the SearchBar. Our final version of the App.jsx should now look like this:

import { useState } from 'react';
import Result from './Result';
import Searchbar from './SearchBar';

function App() {
  const [result, setResult] = useState(null);

  const fetchData = async (value) => {
    const response = await fetch(
      `https://dummyjson.com/products/search?q=${value}&limit=10`
    );
    if (!response.ok) {
      console.log(response);
      return;
    }
    const { products } = await response.json();
    return products;
  };

  return (
    <div>
      <Searchbar
        fetchData={fetchData}
        setResult={setResult}
        suggestionKey="title"
      />
      {result && <Result {...result} />}
    </div>
  );
}

export default App;

In the SearchBar component, we remove the result-state and replace every occurrence of the title with our suggestionKey. The file should now look like this:

import { useState } from 'react';
import useDebounce from './hooks/useDebounce';
import styles from './SearchBar.module.css';

const SearchBar = ({ fetchData, setResult, suggestionKey }) => {
  const [value, setValue] = useState(''); //this is the value of the search bar
  const [suggestions, setSuggestions] = useState([]); // this is where the search suggestions get stored
  const [hideSuggestions, setHideSuggestions] = useState(true);

  const findResult = (value) => {
    setResult(
      suggestions.find((suggestion) => suggestion[suggestionKey] === value)
    );
  };

  useDebounce(
    async () => {
      try {
        const suggestions = await fetchData(value);
        setSuggestions(suggestions || []);
      } catch (error) {
        console.log(error);
      }
    },
    1000,
    [value]
  );

  return (
    <>
      <div className={styles.container}>
        <input
          onFocus={() => setHideSuggestions(false)}
          onBlur={async () => {
            setTimeout(() => {
              setHideSuggestions(true);
            }, 200);
          }}
          type="text"
          className={styles.textbox}
          placeholder="Search data..."
          value={value}
          onChange={(e) => {
            setValue(e.target.value);
          }}
        />
        <div
          className={`${styles.suggestions} ${
            hideSuggestions && styles.hidden
          }`}
        >
          {suggestions.map((suggestion) => (
            <div
              className={styles.suggestion}
              onClick={() => findResult(suggestion[suggestionKey])}
            >
              {suggestion[suggestionKey]}
            </div>
          ))}
        </div>
      </div>
    </>
  );
};

export default SearchBar;

Conclusion

And there you have it, our own reusable search bar including some performance optimizations and without using any third-party library. You can see the full code on my GitHub. Thank you very much for reading.