DEV Community

CinArb/
CinArb/

Posted on • Edited on

React Challenge: Autocomplete functionality in React from scratch

In today's challenge, we will look at implementing autocomplete functionality in React and how to improve the performance of this approach by using the debounce function and the useMemo hook.

I’ll be using a function to call the Rick and Morty REST API to return all the locations from the show.

Creating the Search Bar

I’ll just have a single component called App that includes a form tag. Inside the form, we have the input and datalist element.

With the input element, we will be reading the location the user is typing and then we can bind the datalist to the input. This will provide an autocomplete feature and the user can see a drop-down list with suggestions.

import "./styles.css";
import {useState} from 'react';
import axios from 'axios';
export default function App() {
// state that controlled the input value
const [query, setQuery] = useState("")
// state that hold API data
const [suggestion, setSuggestion] = useState([])
const getLocations = () =>{
  axios.get(`https://rickandmortyapi.com/api/location/?name=${query}`)
  //only add the data with the list of locations to the suggestion array
  .then(data => setSuggestion(data.data?.results))
  .catch((err) => {
    //handle error when user types location that doesn’t exist from API
    if (err.response && err.response.status === 404) {
      setSuggestion(null)
      console.clear()
    }
  })
}
return (
  <form>
    <input
      type="text"
      placeholder="Type location"
      name='query'
      value={query}
      onChange={(e) => {setQuery(e.target.value); getLocations()}}
      list='locations'
    />
    <datalist id='locations'>
    { query.length > 0  && // required to avoid the dropdown list to display the locations fetched before
      suggestion?.map((el, index) => {
        //make sure to only display locations that matches query
        if(el.name.toLowerCase().includes(query)){
          return <option key={index} value={el.name}/>
        }
        return '';
      })
    }
    </datalist>
    <button>Search</button>
 </form>
);
}
Enter fullscreen mode Exit fullscreen mode

In the above snippet we have:

  • one state variable called suggestion. That’s going to hold the info we receive from the API
  • getLocations() that enclose the axios request and will be called when the user is typing on the search bar.
  • The URL we pass through axios will contain the query we get from input
  • From the response, we only want the results array, that contains the name of the locations.
  • We need to catch errors when the user types a location that does not exist. The browser by default will be throwing errors to the console if we continue typing a location that does not exist. So we added console.clear() to avoid that.
  • Finally, as we receive the info, we will be mapping through the array and set the value of the option equal to the name of the location. It’s important to add the key property so we don’t get an error.

https://codesandbox.io/s/autocomplete-zmw5ln?file=/src/App.js

You can take a look at the above codesanbox and see that it works.

The Problem:

Although we have accomplished the task, we must bear in mind that it’s very inefficient to make one API call per keystroke. Imagine in a real project scenario, we could harm the performance of the application and also saturate the API.

The solution:

One of the ways to avoid this is to use a function called debounce which helps us to postpone the execution of the function by a few milliseconds and therefore cancel the previous calls and execute the new one.

If you want to know in-depth about debounce functions feel free to click on here.

function debounce(callback, wait) {
   let timerId;
   return function (...args) {
     const context = this;
     if(timerId) clearTimeout(timerId)
     timerId = setTimeout(() => {
       timerId = null
       callback.apply(context,  args)
     }, wait);
   };
 }
Enter fullscreen mode Exit fullscreen mode

In our case, we are going to pass as a callback the function getLocations with a delay of 300 milliseconds.

<input
      type="text"
      placeholder="Type location"
      name='query'
      value={query}
      onChange={(e) => {setQuery(e.target.value);    debounce(getLocations, 300))}}
      list='locations'
    />
Enter fullscreen mode Exit fullscreen mode

If we try to implement the debounce function in React we will see that nothing happens. The reason why is that each time the user types we are making a new rendering and therefore generating different instances of the debounce function.

As we don't want to generate different instances but to preserve the same one we must seek the help of a hook called useMemo.

import "./styles.css";
import { useState, useMemo } from "react";
import axios from "axios";

export default function App() {
 const [query, setQuery] = useState("");
 // state that hold API data
 const [suggestion, setSuggestion] = useState([]);

 const getLocations = (e) => {
   setQuery(e.target.value)  axios.get(`https://rickandmortyapi.com/api/location/?name=${query}`)
     .then((data) => setSuggestion(data.data?.results))
     .catch((err) => {
       if (err.response && err.response.status === 404) {
         setSuggestion(null);
         console.clear();
     }
   });
 };

 function debounce(callback, wait) {
   let timerId;
   return function (...args) {
     const context = this;
     if(timerId) clearTimeout(timerId)
     timerId = setTimeout(() => {
       timerId = null
       callback.apply(context,  args)
     }, wait);
   };
 }

 const debouncedResults = useMemo(() => debounce(getLocations, 300), []);

 return (
   <form>
     <input
       type="text"
       placeholder="Type location"
       name="query"
       onChange={debouncedResults}
       list="locations"
     />
     <datalist id="locations">
       {query.length > 0 && // // required to avoid the dropdown list to display the locations fetched before
         suggestion?.map((el, index) => {
           if (el.name.toLowerCase().includes(query)) {
             return <option key={index} value={el.name} />;
           }
           return "";
         })}
     </datalist>
     <button>Search</button>
   </form>
 );
}
Enter fullscreen mode Exit fullscreen mode

Now we can see that we have implemented the hook useMemo. Basically what it does is to save the instance of the debounce function and not create new ones every time the user types in the search bar.

That’s all we needed. You can see the final result in the following codesandbox link: https://codesandbox.io/s/autocomplete-debounce-function-and-usememo-e1qzfy?file=/src/App.js:0-1588

Top comments (4)

Collapse
 
creator profile image
John Buchanan

It's annoying the world is changing over to react. Coming from Ionic/Angular to this is just such a hassle. Thanks for outlining some key features, Cin!

Collapse
 
mangor1no profile image
Mangor1no • Edited

It's ok for a fun project, where you are the only user. But don't use onChange event like this in a real life product. By using onChange to call API directly, the API will be harrassed. You should use debounce function for these action, or if you are lazy you can place the API call in onBlur event, so the API will be called only if the input form is not focused.

Collapse
 
cinarb2 profile image
CinArb/

Thank you so much for four feedback. I updated the blog post and added the solution with the debounce function and the useMemo hook. 🥳

Collapse
 
panoxdesign profile image
Patrick Karner

I wonder why this works. You are using useMemo with an empty dependency array, which means it will never change. As far as I understand that means if query changes the new query value shouldn't be available in getLocation, since you are referencing an older reference of getLocations which was created inside of useMemo. So when getLocations get called the query should always be an empty string and I checked it by adding a console.log after your setQuery in getLocations and it always stays as an empty string. Even if I add an interceptor to axios request and console log the request url, it always prints the url without any appended query at the end. But nevertheless it still works.. Can someone please explain why it works? or did I catch something wrong?