DEV Community

Cover image for How to create a search engine with "debounce effect"? πŸ”Ž
Franklin Martinez
Franklin Martinez

Posted on • Edited on

How to create a search engine with "debounce effect"? πŸ”Ž

The purpose of this post is to show a simple way on how to make a small search engine with a debounce effect.
Such a project can be extended in many ways, but I will try to make it something basic but efficient.

🚨 Note: This post requires you to know the basics of React with TypeScript (basic hooks and fetch requests).

Any kind of Feedback is welcome, thanks and I hope you enjoy the article.πŸ€—

Β 

Table of contents.

πŸ“Œ Technologies to be used.

πŸ“Œ What is the "Debounce" effect?

πŸ“Œ Creating the project.

πŸ“Œ First steps.

πŸ“Œ Creating the input.

πŸ“Œ Handling the input status.

πŸ“Œ Creating the function for the API request.

πŸ“Œ Creating the Debounce effect.

πŸ“Œ Making the API call.

πŸ“Œ Creating the Pokemon.tsx component.

πŸ“Œ Using our Pokemon component.

πŸ“Œ Cleaning the logic of our component.

πŸ“Œ 1. Handling the logic to control the input.

πŸ“Œ 2. Handling the logic for the API call.

πŸ“Œ 3. Handling the logic for the Debounce effect.

πŸ“Œ Conclusion.

πŸ“Œ Source code.

Β 

🎈 Technologies to be used.

  • ▢️ React JS (version 18)
  • ▢️ Vite JS
  • ▢️ TypeScript
  • ▢️ Pokemon API
  • ▢️ Vanilla CSS (You can find the styles in the repository at the end of this post)

Β 

🎈 What is the "Debounce" effect?

The debounce effect is when they are not executed at the time of their invocation. Instead, their execution is delayed for a predetermined period of time. If the same function is invoked again, the previous execution is cancelled and the timeout is restarted.

Β 

🎈 Creating the project.

We will name the project: search-debounce (optional, you can name it whatever you like).

npm init vite@latest
Enter fullscreen mode Exit fullscreen mode

We create the project with Vite JS and select React with TypeScript.

Then we execute the following command to navigate to the directory just created.

cd search-debounce
Enter fullscreen mode Exit fullscreen mode

Then we install the dependencies.

npm install
Enter fullscreen mode Exit fullscreen mode

Then we open the project in a code editor (in my case VS code).

code .
Enter fullscreen mode Exit fullscreen mode

Β 

🎈 First steps.

Inside the folder src/App.tsx we delete all the contents of the file and place a functional component that displays a title.

const App = () => {
  return (
    <div className="container">
      <h1> <span>Search Engine</span> whit <span>Debounce Effect</span> </h1>
    </div>
  )
}
export default App
Enter fullscreen mode Exit fullscreen mode

It should look like this πŸ‘€:

Title

Β 

🎈 Creating the input.

Now we create the folder src/components and inside the folder we create the file Input.tsx and inside we add the following:

export const Input = () => {
  return (
    <>
        <label htmlFor="pokemon">Name or ID of a Pokemon</label>
        <input type="text" id="pokemon" placeholder="Example: Pikachu" />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Once done, we import it into the App.tsx file.

import { Input } from "./components/Input"

const App = () => {

  return (
    <div className="container">
      <h1> <span>Search Engine</span> whit <span>Debounce Effect</span> </h1>

      <Input/>
    </div>
  )
}
export default App
Enter fullscreen mode Exit fullscreen mode

It should look like this πŸ‘€:

Input

Β 

🎈 Handling the input status.

🚨 Note: This is NOT the only way to perform this exercise, it is only one option! If you have a better way, I would like you to share it in the comments please. 😌

In this case I am going to handle the input status at a higher level, i.e. the App component of the App.tsx file.

We will do this, because we need the value of the input available in App.tsx, since the request to the API and the debounce effect will be made there.

1 - First we create the state to handle the value of the input.

const [value, setValue] = useState('');
Enter fullscreen mode Exit fullscreen mode

2 - We create a function to update the state of the input when the input makes a change.

This function receives as parameter the event that emits the input, of this event we will obtain the property target and then the property value, which is the one that we will send to our state.

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value); 
Enter fullscreen mode Exit fullscreen mode

3 - Therefore, it is time to send the function and the value of the status to the input.

const App = () => {

  return (
    <div className="container">
      <h1> <span>Search Engine</span> whit <span>Debounce Effect</span> </h1>

      <Input {...{value, onChange}}/>
    </div>
  )
}
export default App
Enter fullscreen mode Exit fullscreen mode

4 - In the Input component we add an interface to receive the properties by parameter in the Input.tsx file.

interface Props {
   value: string;
   onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
Enter fullscreen mode Exit fullscreen mode

5 - We unstructure the properties and add them to the input.

The onChange function, we place it in the onChange property of the input and the same with the value property value.

interface Props {
   value: string;
   onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
}

export const Form = ({ onChange, value }:Props) => {

  return (
    <>
        <label htmlFor="pokemon">Name of a Pokemon</label>
        <input 
          type="text" 
          id="pokemon" 
          placeholder="Example: Pikachu" 
          value={value}
          onChange={onChange}
        />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

And so we already have the status of our input under control. πŸ₯³

Β 

🎈 Creating the function for the API request.

Now we create the src/utils folder and inside we place a file called searchPokemon.ts and add the following function to make the request, and search for a pokemon by its name or ID.

🚨 Note: The API response has more properties than what is represented in the ResponseAPI interface.

This function receives two parameters:

  • pokemon: is the name or ID of the pokemon.
  • signal**: allows to set event listeners. In other words, it will help us to cancel the HTTP request when the component is unmounted or makes a change in the state.

This function returns the pokemon data if everything goes well or null if something goes wrong.

export interface ResponseAPI {
    name: string;
    sprites: { front_default: string }
}

export const searchPokemon = async (pokemon: string, signal?: AbortSignal): Promise<ResponseAPI | null> => {
    try {

        const url = `https://pokeapi.co/api/v2/pokemon/${pokemon.toLowerCase().trim()}`
        const res = await fetch(url, { signal });

        if(res.status === 404) return null

        const data: ResponseAPI = await res.json();
        return data

    } catch (error) {
        console.log((error as Error).message);
        return null
    }
}
Enter fullscreen mode Exit fullscreen mode

Β 

🎈 Creating the Debounce effect.

In the App.tsx file we create a state, which will be used to store the value of the input.

const [debouncedValue, setDebouncedValue] = useState();
Enter fullscreen mode Exit fullscreen mode

As initial state we send the value of the input state (value).

const [value, setValue] = useState('');

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value);

const [debouncedValue, setDebouncedValue] = useState(value);
Enter fullscreen mode Exit fullscreen mode

Now, we create an effect so that when the value of the input changes, we execute the setTimeout function that will update the state of the debouncedValue sending the new value of the input, after 1 second, and thus we will obtain the keyword or the pokemon, to make the request to the API.

At the end of the effect, we execute the cleaning method, which consists of cleaning the setTimeout function, that is why we store it in a constant called timer.

useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), 1000)

    return () => clearTimeout(timer)
}, [value]);
Enter fullscreen mode Exit fullscreen mode

So for the moment our App.tsx file would look like this:

import { useEffect, useState } from 'react';
import { Input } from "./components/Input"

const App = () => {

  const [value, setValue] = useState('');
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value);

  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {

    const timer = setTimeout(() => setDebouncedValue(value), delay || 500)

    return () => clearTimeout(timer)
  }, [value, delay]);

  return (
    <div className="container">
      <h1> <span>Search Engine</span> whit <span>Debounce Effect</span> </h1>

      <Input {...{ value, onChange }} />
    </div>
  )
}
export default App
Enter fullscreen mode Exit fullscreen mode

Β 

🎈 Making the API call.

Once we have the value of the input already with the debounce effect, it is time to make the API call.

For that we will use the function that we created previously, searchPokemon.tsx.

For it, we are going to use an effect.
First we create the controller which is the one that will help us to cancel the HTTP request, as we mentioned before.
Inside the controller we have two properties that interest us:

  • abort(): when executed, cancels the request.
  • signal**: maintains the connection between the controller and the request to know which one to cancel.

The abort() is executed at the end, when the component is unmounted.

useEffect(() => {

    const controller = new AbortController();

    return () => controller.abort();

  }, []);
Enter fullscreen mode Exit fullscreen mode

The dependency of this effect will be the value of the debouncedValue, since every time this value changes, we must make a new request to search for the new pokemon.

useEffect(() => {
    const controller = new AbortController();

    return () => controller.abort();

  }, [debouncedValue])
Enter fullscreen mode Exit fullscreen mode

We make a condition, in which only if the debouncedValue exists and has some word or number, we will make the request.

useEffect(() => {
    const controller = new AbortController();

    if (debouncedValue) {

    }

    return () => controller.abort();
  }, [debouncedValue])
Enter fullscreen mode Exit fullscreen mode

Inside the if we call the searchPokemon function and send it the value of debouncedValue and also the signal property of the controller.

useEffect(() => {
    const controller = new AbortController();

    if (debouncedValue) {
        searchPokemon(debouncedValue, controller.signal)
    }

    return () => controller.abort();
  }, [debouncedValue])
Enter fullscreen mode Exit fullscreen mode

And since the searchPokemon function returns a promise and within the effect it is not allowed to use async/await, we will use .then to resolve the promise and get the value it returns.

useEffect(() => {
    const controller = new AbortController();

    if (debouncedValue) {
        searchPokemon(debouncedValue, controller.signal)
            .then(data => {
            console.log(data) //pokemon | null
        })
    }

    return () => controller.abort();
  }, [debouncedValue])
Enter fullscreen mode Exit fullscreen mode

In the end it should look like this. πŸ‘€

import { useEffect, useState } from 'react';
import { Input } from "./components/Input"
import { searchPokemon } from "./utils/searchPokemon";

const App = () => {

  const [value, setValue] = useState('');
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value);

  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {

    const timer = setTimeout(() => setDebouncedValue(value), delay || 500)

    return () => clearTimeout(timer)
  }, [value, delay]);


  useEffect(() => {

    const controller = new AbortController();

    if (debouncedValue) {
      searchPokemon(debouncedValue, controller.signal)
        .then(data => {
            console.log(data) //pokemon | null
        })
    }

    return () => controller.abort();

  }, [debouncedValue])


  return (
    <div className="container">
      <h1> <span>Search Engine</span> whit <span>Debounce Effect</span> </h1>
      <Input {...{ value, onChange }} />

    </div>
  )
}
export default App
Enter fullscreen mode Exit fullscreen mode

Β 

🎈 Creating the Pokemon.tsx component.

1 - First we create the empty functional component.

export const Pokemon = () => {
  return (
    <></>
  )
}
Enter fullscreen mode Exit fullscreen mode

2 - We add the ResponseAPI interface since we are going to receive by props the pokemon, which can contain the pokemon data or a null value.

import { ResponseAPI } from "../utils/searchPokemon"

export const Pokemon = ({ pokemon }: { pokemon: ResponseAPI | null }) => {

  return (
    <></>
  )
}
Enter fullscreen mode Exit fullscreen mode

3 - We make an evaluation where:

  • If the pokemon property is null, we show the "No results" message.
  • If the pokemon property contains the pokemon data, we show its name and an image.
import { ResponseAPI } from "../utils/searchPokemon"

export const Pokemon = ({ pokemon }: { pokemon: ResponseAPI | null }) => {

  return (
    <>
      {
        !pokemon
          ? <span>No results</span>
          : <div>
            <h3>{pokemon.name}</h3>
            <img src={pokemon.sprites.front_default} alt={pokemon.name} />
          </div>
      }
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

It should look like this if it is loading πŸ‘€:
Loading

It should look like this when there are no results πŸ‘€:
No results

It should look like this there is a pokemon πŸ‘€:
Pokemon

4 - And now finally, we add a last condition, where we evaluate if the pokemon exists (i.e. it is not null) and if it is an empty object we return a fragment.

This is because the initial state for storing pokemon will be an empty object. "{}".

If we don't put that condition, then at the start of our app, even without having typed anything in the input, the "No results" message will appear, and the idea is that it will appear after we have typed something in the input and the API call has been made.

import { ResponseAPI } from "../utils/searchPokemon"

export const Pokemon = ({ pokemon }: { pokemon: ResponseAPI | null }) => {

  if(pokemon && Object.keys(pokemon).length === 0) return <></>;

  return (
    <>
      {
        !pokemon
          ? <span>No results</span>
          : <div>
            <h3>{pokemon.name}</h3>
            <img src={pokemon.sprites.front_default} alt={pokemon.name} />
          </div>
      }
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

This is how our pokemon component would look like, it's time to use it. 😌

Β 

🎈 Using our Pokemon component.

In the App.tsx file we will add 2 new states:

  • To store the pokemon found, which will have an initial value of an empty object.
  • To handle a loading on what the API call is made, which will have an initial value of false.
const [pokemon, setPokemon] = useState<ResponseAPI | null>({} as ResponseAPI);
const [isLoading, setIsLoading] = useState(false)
Enter fullscreen mode Exit fullscreen mode

Now inside the effect where we make the call to the API through the function searchPokemon, before making the call we send the value of true to the setIsLoading to activate the loading.

Then, once we get the data inside the .then we send the data to the setPokemon (which can be the pokemon or a null value).
And finally we send the value of false to setIsLoading to remove the loading.

useEffect(() => {

    const controller = new AbortController();

    if (debouncedValue) {

      setIsLoading(true)

      searchPokemon(debouncedValue, controller.signal)
        .then(data => {
          setPokemon(data);
          setIsLoading(false);
        })
    }

    return () => controller.abort();
  }, [debouncedValue])
Enter fullscreen mode Exit fullscreen mode

Once the pokemon is stored, in the JSX we place the following condition:

  • If the value of the isLoading status is true, we display the "Loading Results... " message.
  • If the value of the isLoading status is false, we show the Pokemon component, sending it the pokemon.
return (
    <div className="container">
      <h1> <span>Search Engine</span> whit <span>Debounce Effect</span> </h1>
      <Input {...{ value, onChange }} />
      {
        isLoading 
          ? <span>Loading Results...</span>
          : <Pokemon pokemon={pokemon}/>
      }
    </div>
  )
Enter fullscreen mode Exit fullscreen mode

And everything together would look like this πŸ‘€:

import { useEffect, useState } from 'react';

import { Input } from "./components/Input"
import { Pokemon } from "./components/Pokemon";

import { searchPokemon } from "./utils/searchPokemon";

import { ResponseAPI } from "./interface/pokemon";

const App = () => {

  const [pokemon, setPokemon] = useState<ResponseAPI | null>({} as ResponseAPI);
  const [isLoading, setIsLoading] = useState(false)

  const [value, setValue] = useState('');
  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value);

  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {

    const timer = setTimeout(() => setDebouncedValue(value), delay || 500)

    return () => clearTimeout(timer)
  }, [value, delay]);

  useEffect(() => {

    const controller = new AbortController();

    if (debouncedValue) {

      setIsLoading(true)

      searchPokemon(debouncedValue, controller.signal)
        .then(data => {
          setPokemon(data);
          setIsLoading(false);
        })
    }

    return () => controller.abort();
  }, [debouncedValue])


  return (
    <div className="container">
      <h1> <span>Search Engine</span> whit <span>Debounce Effect</span> </h1>
      <Input {...{ value, onChange }} />
      {
        isLoading 
          ? <span>Loading Results...</span>
          : <Pokemon pokemon={pokemon}/>
      }

    </div>
  )
}
export default App
Enter fullscreen mode Exit fullscreen mode

That's a lot of logic in one component right? 😱

Now it's our turn to refactor!

Β 

🎈 Cleaning the logic of our component.

We have a lot of logic in our component so it is necessary to separate it into several files:

  • Logic to control the input.
  • Debounce logic.
  • Logic to make the API call and handle the pokemon. And as this logic makes use of hooks like useState and useEffect, then we must place them in a custom hook.

The first thing will be to create a new folder src/hooks.

1. Handling the logic to control the input.

Inside the folder src/hooks we create the following file useInput.ts**.
And we place the logic corresponding to the handling of the input.

import { useState } from 'react';

export const useInput = (): [string, (e: React.ChangeEvent<HTMLInputElement>) => void] => {

    const [value, setValue] = useState('');

    const onChange = (e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value);

    return [value, onChange]
}
Enter fullscreen mode Exit fullscreen mode

Then we call useInput in the App.tsx file.

import { useEffect, useState } from 'react';

import { Input } from "./components/Input"
import { Pokemon } from "./components/Pokemon";

import { useInput } from "./hooks/useInput";

import { searchPokemon } from "./utils/searchPokemon";

import { ResponseAPI } from "./interface/pokemon";

const App = () => {

  const [value, onChange] = useInput();

  const [pokemon, setPokemon] = useState<ResponseAPI | null>({} as ResponseAPI);
  const [isLoading, setIsLoading] = useState(false)

  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {

    const timer = setTimeout(() => setDebouncedValue(value), delay || 500)

    return () => clearTimeout(timer)
  }, [value, delay]);

  useEffect(() => {

    const controller = new AbortController();

    if (debouncedValue) {

      setIsLoading(true)

      searchPokemon(debouncedValue, controller.signal)
        .then(data => {
          setPokemon(data);
          setIsLoading(false);
        })
    }

    return () => controller.abort();
  }, [debouncedValue])


  return (
    <div className="container">
      <h1> <span>Search Engine</span> whit <span>Debounce Effect</span> </h1>
      <Input {...{ value, onChange }} />
      {
        isLoading 
          ? <span>Loading Results...</span>
          : <Pokemon pokemon={pokemon}/>
      }

    </div>
  )
}
export default App
Enter fullscreen mode Exit fullscreen mode

Β 

2. Handling the logic for the API call.

Inside the folder src/hooks we create the following file useSearchPokemon.ts.

We place the logic related to make the request to the API and show the pokemon.

This custom hook receives as parameter a string called search, which is the name of the pokemon or the ID. And we send that parameter to the function that makes the API call searchPokemon.

🚨 Note: Observe the If part in the effect, at the end we place an else where if the debouncedValue is empty, we will not make a call to the API and we send the value of an empty object to setPokemon.

import { useState, useEffect } from 'react';
import { ResponseAPI } from '../interface/pokemon';
import { searchPokemon } from '../utils/searchPokemon';

export const useSearchPokemon = (search: string) => {

    const [pokemon, setPokemon] = useState<ResponseAPI | null>({} as ResponseAPI);

    const [isLoading, setIsLoading] = useState(false)


    useEffect(() => {
        const controller = new AbortController();

        if (search) {

            setIsLoading(true);

            searchPokemon(search, controller.signal)
                .then(data => {
                    setPokemon(data);
                    setIsLoading(false);
                });

        }else { setPokemon({} as ResponseAPI) }

        return () => controller.abort();

    }, [search])

    return {
        pokemon,
        isLoading
    }
}
Enter fullscreen mode Exit fullscreen mode

Then we call useSearchPokemon in the App.tsx file.

import { useEffect, useState } from 'react';

import { Input } from "./components/Input"
import { Pokemon } from "./components/Pokemon";

import { useInput } from "./hooks/useInput";
import { useSearchPokemon } from "./hooks/useSearchPokemon";

import { searchPokemon } from "./utils/searchPokemon";

import { ResponseAPI } from "./interface/pokemon";

const App = () => {

  const [value, onChange] = useInput();

  const [debouncedValue, setDebouncedValue] = useState(value);

  const { isLoading, pokemon } = useSearchPokemon(debouncedValue)

  useEffect(() => {

    const timer = setTimeout(() => setDebouncedValue(value), delay || 500)

    return () => clearTimeout(timer)
  }, [value, delay]);



  return (
    <div className="container">
      <h1> <span>Search Engine</span> whit <span>Debounce Effect</span> </h1>
      <Input {...{ value, onChange }} />
      {
        isLoading 
          ? <span>Loading Results...</span>
          : <Pokemon pokemon={pokemon}/>
      }

    </div>
  )
}
export default App
Enter fullscreen mode Exit fullscreen mode

Β 

3. Handling the logic for the Debounce effect.

Inside the folder src/hooks we create the following file useDebounce.ts and place all the logic to handle the debounce effect.

This custom hook, receives 2 parameters:

  • value: is the value of the input status.
  • delay**: is the amount of time you want to delay the debounce execution and is optional.

🚨 Note: the delay property is used as the second parameter of the setTimeout function, where in case delay is undefined, then the default time will be 500ms.
And also, we add the delay property to the effect dependencies array.

import { useState, useEffect } from 'react';

export const useDebounce = (value:string, delay?:number) => {

    const [debouncedValue, setDebouncedValue] = useState(value);

    useEffect(() => {

        const timer = setTimeout(() => setDebouncedValue(value), delay || 500)

        return () => clearTimeout(timer)
    }, [value, delay]);

    return debouncedValue
}
Enter fullscreen mode Exit fullscreen mode

Then we call useDebounce in the App.tsx file.

import { useEffect, useState } from 'react';
import { Input } from "./components/Input"
import { Pokemon } from "./components/Pokemon";
import { useInput } from "./hooks/useInput";
import { useSearchPokemon } from "./hooks/useSearchPokemon";
import { useDebounce } from "./hooks/useDebounce";
import { searchPokemon } from "./utils/searchPokemon";
import { ResponseAPI } from "./interface/pokemon";

const App = () => {

  const [value, onChange] = useInput();

  const debouncedValue = useDebounce(value, 1000);  

  const { isLoading, pokemon } = useSearchPokemon(debouncedValue)

  return (
    <div className="container">
      <h1> <span>Search Engine</span> whit <span>Debounce Effect</span> </h1>

      <Input {...{ value, onChange }} />

      {
        isLoading 
          ? <span>Loading Results...</span>
          : <Pokemon pokemon={pokemon}/>
      }

    </div>
  )
}
export default App
Enter fullscreen mode Exit fullscreen mode

And so our App.tsx component was cleaner and easier to read. πŸ₯³

Β 

🎈 Conclusion.

The whole process I just showed, is one of the ways you can make a search engine with debounce effect. πŸ”Ž

I hope I helped you understand how to do this exercise,thank you very much for making it this far! πŸ€—

I invite you to comment if you know any other different or better way of how to make a debounce effect for a search engine. πŸ™Œ

Β 

🎈 Source code.

GitHub logo Franklin361 / search-engine-debounce-effect

Creating a search engine with debounce effect with React JS πŸš€

Search Engine - Debounce Effect πŸ”

Creating a search engine with debounce effect with React JS and Pokemon API πŸš€

Page

Technologies πŸ§ͺ

  • React JS
  • Typescript
  • Vite JS

Instalation. πŸš€

1. Clone the repository

 git clone https://github.com/Franklin361/journal-app
Enter fullscreen mode Exit fullscreen mode

2. Run this command to install the dependencies.

 npm install
Enter fullscreen mode Exit fullscreen mode

3. Run this command to raise the development server.

 npm run dev
Enter fullscreen mode Exit fullscreen mode



Links. ⛓️

Demo of the app πŸ”— https://search-engine-debounce.netlify.app

Here's the link to the tutorial in case you'd like to take a look at it! πŸ‘€






Top comments (2)

Collapse
 
eagledev_ profile image
Eagle Dev

Thanks for this information!
It was really useful for me! ❣️

Collapse
 
cassidoo profile image
Cassidy Williams

Thanks for writing this up with so much detail, super helpful!