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>
);
}
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);
};
}
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'
/>
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>
);
}
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