Ever typed into a search input and noticed your app lags or sends a request on every single keystroke? This happens because the search function fires too fast, which can overwhelm your REST API.

Today, we will fix that by building a searchable dropdown component in React using Vite that waits for the user to stop typing before fetching data.

  • How to set up a fast development environment using Vite and React
  • What debounce means and why it saves server resources
  • How to fetch data from a REST API using the native fetch function
  • How to handle user clicks outside the dropdown to close it

Setting Up the Project and State

First, we need to create our component file and set up the basic state for our search query, the fetched results, and whether the dropdown is open. We will use React's useState hook to manage these values locally in our component.

import React, { useState } from 'react';

export default function SearchDropdown() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className="dropdown">
      <input 
        type="text" 
        value={query} 
        onChange={(e) => setQuery(e.target.value)} 
        placeholder="Search users..."
      />
    </div>
  );
}

This basic setup gives us a controlled input where the React state matches whatever the user types.

Adding the Debounce Logic

Debouncing is a programming practice that delays a function's execution until a certain amount of time has passed since the last time it was called. We will use a custom hook or JavaScript's built-in setTimeout function inside a useEffect hook to wait for the user to pause typing before calling our REST API.

import React, { useState, useEffect } from 'react';

export default function SearchDropdown() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  useEffect(() => {
    if (query.trim() === '') {
      setResults([]);
      return;
    }

    const timerId = setTimeout(async () => {
      try {
        const response = await fetch(`https://jsonplaceholder.typicode.com/users?name_like=${query}`);
        const data = await response.json();
        setResults(data);
      } catch (error) {
        console.error('Failed to fetch data', error);
      }
    }, 300); // Wait 300ms after last keystroke

    return () => clearTimeout(timerId);
  }, [query]);

  return (
    <div className="dropdown">
      <input 
        type="text" 
        value={query} 
        onChange={(e) => setQuery(e.target.value)} 
        placeholder="Search users..."
      />
      {results.length > 0 && (
        <ul className="results-list">
          {results.map(user => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

The useEffect cleanup function calling clearTimeout is the secret sauce that stops old requests from firing while the user is still typing.

Common mistakes

One common mistake is forgetting to clear the timeout in the useEffect return function, which can lead to memory leaks and unexpected API calls. Another mistake is failing to handle loading and error states, leaving the user staring at a blank screen if the REST API fails or responds slowly.

Take this code and add a loading spinner to the dropdown list so users know their search is actively processing.