Back to all notes

Debounce: Wait for the User to Finish Typing

Delay expensive work (like search) until input settles using a simple debounced hook.

2 min read

Wire an effect straight to a search input and it fires on every keystroke, and most of those runs are for half-typed words nobody meant to search.

What Happens on Every Keystroke

This is the version I write first and then regret.

import { useState, useEffect } from "react";

function App() {
  const [searchTerm, setSearchTerm] = useState("");

  useEffect(() => {
    // Imagine this is a fetch; it runs on every keystroke
    console.log("Searching for:", searchTerm);
  }, [searchTerm]);

  return (
    <div>
      <h1>Debounce</h1>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search..."
      />
      <p>Searching for: {searchTerm}</p>
    </div>
  );
}

export default App;

The effect depends on searchTerm, so every change runs it. Swap the log for a fetch and you’re requesting on every letter, flooding the app and the network, and the UI can start to feel sluggish.

Wait for the Pause

Debouncing delays the work until the input has settled, then runs it once. I keep that in a hook.

import { useState, useEffect } from "react";

function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => setDebouncedValue(value), delay);

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

  return debouncedValue;
}

function App() {
  const [searchTerm, setSearchTerm] = useState("");
  const debouncedSearchTerm = useDebounce(searchTerm, 500);

  useEffect(() => {
    if (debouncedSearchTerm) {
      console.log("Searching for:", debouncedSearchTerm);
    }
  }, [debouncedSearchTerm]);

  return (
    <div>
      <h1>Debounce</h1>
      <input
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search..."
      />
      <p>Search term: {searchTerm}</p>
      <p>Debounced: {debouncedSearchTerm}</p>
    </div>
  );
}

export default App;

State inside the hook is initialized with the incoming value. When value changes, the effect arms a timeout to copy it over after delay, and the cleanup cancels any pending timer. The copy only lands once typing pauses, and the consuming effect reads debouncedSearchTerm, so requests and other expensive work fire on settled input.

Always clean up the timeout; that’s what prevents leaks. When the debounced value drives real requests, cancel anything in flight as it changes. Leading vs trailing behavior is worth deciding too. This hook is trailing, which search usually wants.