Build a Live Search Bar in React: A Step-by-Step Guide

This article provides a tutorial for building a live search bar in React using functional components and hooks. The end result will be a search bar that filters through a list of characters based on user input.

To get started, ensure that you have Node.js installed on your computer and have TypeScript installed as well. Once you have these prerequisites, you can create your React app using the following command in your terminal:

npx create-react-app live-search --template typescript

Next, create a simple search component using the following code:

  // src/components/Searchbar.tsx
  export default function Searchbar (_props) {
    return (
      <div className="w-full">
        <input type="search" className="w-full">
      </div>
    )
  }

To handle state, you can use the useReducer hook. Create a reducer function that handles different actions and returns a new state based on the action. The following code shows the reducer function, the initial state, and how to use the hook:

// src/components/Searchbar.tsx

// types
type SearchResult = {
  id: string,
  name: string,
  age: number
}

type State = {
    results: SearchResult[]
    isLoading: boolean
    error?: string,
    query: string
}

type Action =
    | { type: 'request', }
    | {
        type: 'success', results: SearchResult[]
    }
    | { type: 'failure', error: string }
    | { type: 'setQuery', query: string }

// reducer 
function reducer(state: State, action: Action): State {
    switch (action.type) {
        case "request": {
            return { ...state, isLoading: true }
        };

        case "success": {
            return { ...state, isLoading: false, results: action.results }
        };

        case "failure": {
            return { ...state, isLoading: false, error: action.error }
        };

        case "setQuery": {
            return { ...state, query: action.query }
        };

        default: {
            throw Error("Unknown Action")
        }
    }
}

const initialState: State = { isLoading: false, results: [], query: "" }

export default function Searchbar (_props) {
  const [state, dispatch] = useReducer(reducer, initialState)

  // handle input change
  function handleChange (e) {
    dispatch({
      type: "setQuery",
      query: e.target.value
    })
  }

  return (
    <div className="w-full">
      <input onChange={handleChange} value={state.query} type="search" className="w-full">
    </div>
  )
}


Right now, whenever you type something into the searchbar the state changes. That's great! Now let's add the search logic.

We want to send a request every time the input changes. so let's create a custom react hook that will handle that for us every time the input changes.

  // src/components/searchbar.tsx
  // ...old code above
  import { Dispatch, useEffect, useState } from "react"

  const getCharacters = async (query: string): Promise<SearchResult[]> => {
    return await fetch(`API/URL`, {
        body: JSON.stringify({
            keyword: query
        })
    })
        .then(res => res.json())
        .then(data => data.data)
        .catch(err => {
            if (err.message === "DOMException: The user aborted a request.") {
                console.log("request aborted")
            } else {
                throw err
            }
        })
  }

  const useLiveSearch = (dispatch: Dispatch<Action>, query: string) => {
    useEffect(() => {
      // don't send any requests if the input is less than 3 characters
      if (query.length < 3) return
      // set loading state to true
      dispatch({ type: "request" })
      try {
          // this is a helper method that fetches the data for us
          const data = await getCharacters(query)
          if (data) {
              dispatch({ type: "success", results: data })
          }
      } catch (err) {
        // handle errors
          console.error(err)
          dispatch({ "type": "failure", error: "Something went wrong" })
      }
    }, [query])
  }

  export default function Searchbar (_props) {
    const [state, dispatch] = useReducer(reducer, initialState)
    const {query, isLoading, results, error} = state
    
    useLiveSearch(dispatch, query)
    
    // handle input change
    function handleChange (e) {
      dispatch({
        type: "setQuery",
        query: e.target.value
      })
    }

    return (
      <div className="w-full">
        <input onChange={handleChange} value={query} type="search" className="w-full">
      </div>
    )
  }

To improve the performance of the app, we need to solve a problem. for example: whenever a user types a word of four characters, four requests are sent to the server, which is not optimal. To solve this, we use a technique called debouncing. Debouncing is achieved by delaying the execution of our code, the API requests, and waiting for a bit of time before executing the code if the input changes. This technique reduces the requests sent to the server and thus improves performance.

Here is the modified code:

  // old code above...
  // modify the api method to accept an abort signal
  const getCharacters = async (query: string, signal): Promise<SearchResult[]> => {
    return await fetch(`API/URL`, {
        signal,
        body: JSON.stringify({
            keyword: query
        })
    })
        .then(res => res.json())
        .then(data => data.data)
        .catch(err => {
            if (err.message === "DOMException: The user aborted a request.") {
                console.log("request aborted")
            } else {
                throw err
            }
        })
  }

  const useLiveSearch = (dispatch: Dispatch<Action>, query: string) => {
    useEffect(() => {
      // add an abort controller to abort requests if the query changes before a request is finished
        const controller = new AbortController();
        (async function () {
            dispatch({ type: "request" })
            try {
                const data = await getCharacters(query, controller.signal)
                if (data) {
                    dispatch({ type: "success", results: data })
                }
            } catch (err) {
                console.error(err)
                dispatch({ "type": "failure", error: "Something went wrong" })
            }
        })()
        return () => controller.abort()
    }, [query])
}

  export default function Searchbar (_props) {
    const [state, dispatch] = useReducer(reducer, initialState)
    const {query, isLoading, results, error} = state
    
    const [debounceValue, setDebounceValue] = useState<string>('')
    
    useLiveSearch(dispatch, query)

    function handleChange (e) {
      setDebounceValue(e.target.value)
    }

    useEffect(() => {
      if (debounceValue.length < 3) {
          return
      }
      const timeOut = setTimeout(() => {
          dispatch({ type: "setQuery", query: debounceValue })
      }, 400 ); // you can use any interval you want

      // clean up
      return () => clearTimeout(timeOut)
    }, [debounceValue])
    
    return (
      <div className="w-full">
        <input onChange={handleChange} value={query} type="search" className="w-full">
      </div>
    )
  }

Great! now our app is more performant.

But let's take it one step further. To avoid redundant requests, we can use a technique called memoization, which caches the return values of expensive function calls. In our case, we use memoization to cache API calls. We can use the lodash memoize method. So let's install it.

npm i lodash.memoize

Here is the final version:

  import { Dispatch, useEffect, useState } from "react"
  import memoize from "lodash.memoize"

  // types
  type SearchResult = {
    id: string,
    name: string,
    age: number
  }

  type State = {
      results: SearchResult[]
      isLoading: boolean
      error?: string,
      query: string
  }

  type Action =
      | { type: 'request', }
      | {
          type: 'success', results: SearchResult[]
      }
      | { type: 'failure', error: string }
      | { type: 'setQuery', query: string }

  // reducer 
  function reducer(state: State, action: Action): State {
      switch (action.type) {
          case "request": {
              return { ...state, isLoading: true }
          };

          case "success": {
              return { ...state, isLoading: false, results: action.results }
          };

          case "failure": {
              return { ...state, isLoading: false, error: action.error }
          };

          case "setQuery": {
              return { ...state, query: action.query }
          };

          default: {
              throw Error("Unknown Action")
          }
      }
  }

  const initialState: State = { isLoading: false, results: [], query: "" }

  const getCharacters = async (query: string, signal): Promise<SearchResult[]> => {
    return await fetch(`API/URL`, {
        signal,
        body: JSON.stringify({
            keyword: query
        })
    })
        .then(res => res.json())
        .then(data => data.data)
        .catch(err => {
            if (err.message === "DOMException: The user aborted a request.") {
                console.log("request aborted")
            } else {
                throw err
            }
        })
  }
  
  const memoizedGetCharacters = memoize(getCharacters)

  const useLiveSearch = (dispatch: Dispatch<Action>, query: string) => {
    useEffect(() => {
      // create an abort controller to abort requests if the query changes before a request is finished
        const controller = new AbortController();
        (async function () {
            dispatch({ type: "request" })
            try {
                // use the memoized method
                const data = await memoizedGetCharacters(query, controller.signal)
                if (data) {
                    dispatch({ type: "success", results: data })
                }
            } catch (err) {
                console.error(err)
                dispatch({ "type": "failure", error: "Something went wrong" })
            }
        })()
        return () => controller.abort()
    }, [query])
  }

  export default function Searchbar (_props) {
    const [state, dispatch] = useReducer(reducer, initialState)
    const {query, isLoading, results, error} = state
    
    const [debounceValue, setDebounceValue] = useState<string>('')
    
    useLiveSearch(dispatch, query)

    function handleChange (e) {
      setDebounceValue(e.target.value)
    }

    useEffect(() => {
      if (debounceValue.length < 3) {
          return
      }
      const timeOut = setTimeout(() => {
          dispatch({ type: "setQuery", query: debounceValue })
      }, 400 ); // you can use any interval you want

      // clean up
      return () => clearTimeout(timeOut)
    }, [debounceValue])
    
    return (
      <div className="w-full">
        <input onChange={handleChange} value={query} type="search" className="w-full">
      </div>
    )
  }

Wrapping up

In this tutorial, we learned how to build a live search bar in React using functional components and hooks.

We started by creating a simple search component and handling state with the useReducer hook. We then added search logic by creating a custom hook that sends a request to the server every time the input changes. Finally, we used the debouncing technique to improve the app's performance by delaying the execution of our code and reducing the number of requests sent to the server. With these techniques, we can create a fast and responsive search bar that improves the user experience.

That's it. You're all good to go!