Search bars are a UI element you encounter on most websites; they help users find resources quickly through automatic suggestions.

Adding a search bar to your application can drastically improve the UX by making your resources more accessible and more intuitive to search for.

In this tutorial, we’ll create a simple, reusable, and customizable Search Bar Component in React without the use of any additional libraries apart from axios.

This is a quick overview of the features our Search Bar component will include:

  • The component will attempt to fetch and display suggestions based on the user’s search bar input.
  •  We will debounce changes made to the search bar’s content to limit the hits on the Backend.
  •  Once the search bar input loses focus, the suggestions will disappear.
  •  If the user clicks on a suggestion, we’ll load the contents of that specific suggestion by doing a lookup based on the title.

Project Setup

We’ll first start by setting up our new project using Vite. If you’d like to know more about why Vite might be a better alternative than Webpack when creating new React projects and how to set up a project properly using it, I’d recommend checking out our previous article that you may find here.

# Setup Vite App using NPM
npm create vite

# Setup Vite App using Yarn
yarn create vite

For this article, we’ll be using React coupled with vanilla JavaScript.

After having created the project, we’ll install the dependencies and run the application using the following combined command:

yarn && yarn dev

After that, we’ll remove the App.css file and its import inside the App.jsx file. We’ll also remove all boilerplate code within the App.jsx. The root App.jsx file should then look like this:

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

export default App;

The index.css file should also look something like this:

body {
  margin: 0;
  padding: 0;
}

Textbox and Data Retrieval

Now that we’ve got the initial configuration done, we can proceed by creating a SearchBar.jsx file to store our SearchBar component implementation.

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

const SearchBar = () => {
  const [value, setValue] = useState('');
  const [suggestions, setSuggestions] = useState([]);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const { data } = await axios.get(
          `https://dummyjson.com/products/search?q=${value}`
        );

        setSuggestions(data.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;

We’ll start by creating an input and binding it to a state in the component. Then, we’ll use the useEffect hook’s callback to handle the data retrieval process.

We’ll use a fake REST API called https://dummyjson.com to retrieve searchable test data.

As a side note, what we’re doing today is by no means a production-ready implementation of fetching data and error handling – if you’d like to get a better grasp on Error Handling in React, you can check out our article on the subject here.

Now, we can style our textbox a bit by creating the SearchBar.module.css file with the following contents:

.textbox {
  border: 1px solid #f3f3f3;
  height: 2rem;
  box-sizing: border-box;
  padding: 1px 10px;
  outline: none;
  transition: 0.2s ease-in-out;
}

.textbox:focus {
  transition: 0.2s ease-in-out;
  border: 1px solid blue;
}

Displaying the Results

Now, let’s show the search bar suggestions; for that, we’ll need to create a piece of state to control the visibility of the results:

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

Furthermore, we’ll also 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 div element that will contain a list of suggestions; each suggestion will be composed of an div element containing the suggestion text. Within the child divs, their respective title will be rendered.

One important thing to notice here is that the className hidden will only be added in case the value of hideSuggestions will be true.

Furthermore, we’ll also need to provide two extra props to the input element:

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

The first one is relatively simple – we show the suggestions once the textbox is focused.

If we move outside the textbox, we’ll need to wait until we can hide the suggestions again. That’s because if we’re to click on a specific suggestion, the onBlur input event will be triggered before the suggestion has been triggered.

Without waiting, we can’t check whether the user has just clicked somewhere to lose focus from the search bar or whether he/her clicked on a suggestion; a delay of 200ms should prevent this from taking place.

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

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

.textbox {
  border: 1px solid #f3f3f3;
  height: 2rem;
  outline: none;
  box-sizing: border-box;
  padding: 1px 10px;
  transition: 0.2s ease-in-out;
  width: 100%;
}

.textbox:focus {
  /* We'll change the border color of the textbox as long as it is being focused */
  border: 1px solid blue;
  transition: 0.2s ease-in-out;
}

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

.suggestions.hidden {
  /* Will hide the suggestions */
  visibility: hidden; // We can also use `opacity: 0`
}

.suggestion {
  /* Styling of a single suggestion */
  cursor: pointer;
  box-sizing: border-box;
  padding: 1px 10px;
  height: 2rem;
  display: flex;
  align-items: center;
}

.suggestion:hover {
  /* Changing background color on hover */
  background-color: #f3f3f3;
}

Loading the Result State

Now, we’d like to show the result once the user clicks on a suggestion; for that, we’ll need another piece of state, as well as a function responsible for finding the suggestion based on a given title:

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

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

Within the suggestion element, we can now call this method in case an onClick event will get triggered:

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

To display the results, we’ll then create an additional component named Result inside a new  Result.jsx file:

import React from 'react';

// Names of the props we expect to receive
const keys = ['title', 'description', 'price', 'rating', 'category'];

const Result = (props) => (
  <div>
    {keys.map((key) => (
      <span>{key.charAt(0) + key.slice(1)}: {props[key]}</span>
    )}
  </div>
);

export default Result;

We can now use this component by integrating it within the SearchBar component; for that, we could wrap the whole component into a React Fragment.

We’ll place the search bar container and the result within the fragment – our SearchBar.jsx file should look something like this:

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

const SearchBar = () => {
  const [value, setValue] = useState(''); // Here we'll store the value of the search bar's text input
  const [suggestions, setSuggestions] = useState([]); // This is where we'll store the retrieved suggestions
  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 { data } = await axios.get(
          `https://dummyjson.com/products/search?q=${value}`
        );

        setSuggestions(data.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;

Further Performance Optimizations

We’ve now developed the core functionality of the search bar; however, there might be some issues, especially if you have many resources to work with:

  1. The fetching process will be triggered each time we add or remove a letter from the textbox. If we search for the value “phone”, for example, the API endpoint will be called five times when, theoretically, it should only be called once.
  2. If we were to have thousands of products, for example, loading all of them into our suggestions state at once would take up a significant amount of space. Chances are, the user wouldn’t scroll through thousands of suggestions anyways.

A solution we’ll use to the first issue would be the use of something called a Debounce Function, which would enable our application to only trigger a specific event (which, in our case, would be the fetching/retrieval of our suggestions from the API) after a certain delay.

For example, our debounce function would ensure that the suggestions’ fetching process will only be triggered once the user has stopped typing for at least one second / 1000 milliseconds (Which is the delay we’re setting).

To make everything work, we’ll have to use some custom hooks; WebDevSimplified has created the ones from today’s article, and they have made the integration of debounce very simple for us.

First, create a hooks/ directory under the src/ directory. Then, make the following two files: useTimeout.js , useDebounce.js and paste this code into the respective file:

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

export const 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 const useDebounce = (callback, delay, deps) => {
  const { reset, clear } = useTimeout(callback, delay);

  useEffect(reset, [...deps, reset]);
  useEffect(clear, []);
}

For a detailed explanation of how these hooks work and why we shall use them in other scenarios, I’d recommend you check this video created by WebDevSimplified.

To use the useDebounce hook, we’ll need to replace the useEffect. We’ll also add a 1000ms (1 second) delay as an argument right before the dependency array. This block should now look like this:

useDebounce(
  () => {
    const fetchData = async () => {
      try {
        const { data } = await axios.get(
          `https://dummyjson.com/products/search?q=${value}`
        );

        setSuggestions(data.products);
      } catch (error) {
        console.log(error);
      }
    };
    
    fetchData();
  },
  1000,
);  

For the second issue listed above, we limit the maximum number of suggestions fetched to 10. Thankfully, the dummyjson API has a query parameter called limit, which does precisely what the name suggests – It sets a limit on the number of records that shall be retrieved.

In real-world projects, there usually are plenty of such options (limit, start, end, etc.).

For our example, we need to include the limit parameter in the request:

const { data } = await axios.get(
  `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. We will separate all variables and functions not part of the search bar. 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, that would be 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 { data } = await axios.get(
    `https://dummyjson.com/products/search?q=${value}&limit=10`
  );

  return data.products;
};

As you can see, we removed the error handling from the function, which 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,
);

As you can see, we’re setting the suggestions to either be the result of calling our fetchData function or an empty array – This avoids further overhead when accessing array methods.

Now, let’s move our result, as well as our displayed suggestion property, to the App.jsx file.

For that, we include the resulting state and the rendering of the results. We’ll then pass the setResult function and the suggestion key as props to the SearchBar component.

Our final version of the App.jsx should then look something like this:

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

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

  const fetchData = async (value) => {
    const { data } = await axios.get(
      `https://dummyjson.com/products/search?q=${value}&limit=10`
    );

    return data.products;
  };

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

export default App;

In the SearchBar component, we remove the resulting 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]
  );

  const handleFocus = () => {
    setHideSuggestions(false)
  };

  const handleBlur = () => {
    setTimeout(() => {
      setHideSuggestions(true);
    }, 200);
  };

  const handleSearchInputChange = (e) => {
    setValue(e.target.value);
  };

  return (
    <>
      <div className={styles['container']}>
        <input
          onFocus={handleFocus}
          onBlur={handleBlur}
          type="search"
          className={styles['textbox']}
          placeholder="Search data..."
          value={value}
          onChange={handleSearchInputChange}
        />
        <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 we go – we created our reusable search bar that includes performance optimizations without using any third-party library.

I hope you’ve enjoyed the read, and I’m looking forward to knowing your thoughts on this article.

Cheers!

👋 Hey, I'm Vlad Mihet
I'm Vlad Mihet, a blogger & Full-Stack Engineer who loves teaching others and helping small businesses develop and improve their technical solutions & digital presence.

💬 Leave a comment

Your email address will not be published. Required fields are marked *

We will never share your email with anyone else.